v6 update

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

View File

@ -0,0 +1,175 @@
<template>
<BasePage>
<h1 class="mb-2">Sample Table Local</h1>
<BaseTable :data="data" :columns="columns">
<template #cell-status="{ row }">
<span
v-if="row.data.status === 'Active'"
class="
inline-flex
px-2
text-xs
font-semibold
leading-5
text-green-800
bg-green-100
rounded-full
"
>
{{ row.data.status }}
</span>
<span
v-else
class="
inline-flex
px-2
text-xs
font-semibold
leading-5
text-red-800
bg-red-100
rounded-full
"
>
{{ row.data.status }}
</span>
</template>
<template #cell-actions="{ row }">
<base-dropdown width-class="w-48" margin-class="mt-1">
<template #activator>
<div class="flex items-center justify-center">
<DotsHorizontalIcon class="w-6 h-6 text-gray-600" />
</div>
</template>
<base-dropdown-item>
<document-text-icon
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
New Invoice
</base-dropdown-item>
<base-dropdown-item>
<document-icon
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
New Estimate
</base-dropdown-item>
<base-dropdown-item>
<user-icon
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
New Customer
</base-dropdown-item>
</base-dropdown>
</template>
</BaseTable>
<h1 class="mt-8 mb-2">Sample Table Remote</h1>
<BaseTable :data="fetchData" :columns="columns2"> </BaseTable>
</BasePage>
</template>
<script>
import { computed, reactive } from 'vue'
import { useItemStore } from '@/scripts/admin/stores/item'
import {
UserIcon,
DocumentIcon,
DocumentTextIcon,
DotsHorizontalIcon,
} from '@heroicons/vue/solid'
export default {
components: {
BaseTable,
DotsHorizontalIcon,
UserIcon,
DocumentIcon,
DocumentTextIcon,
},
setup() {
const itemStore = useItemStore()
const data = reactive([
{ name: 'Tom', age: 3, image: 'tom.jpg', status: 'Active' },
{ name: 'Felix', age: 5, image: 'felix.jpg', status: 'Disabled' },
{ name: 'Sylvester', age: 7, image: 'sylvester.jpg', status: 'Active' },
])
const columns = computed(() => {
return [
{
key: 'name',
label: 'Name',
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'age', label: 'Age' },
{ key: 'image', label: 'Image' },
{ key: 'status', label: 'Status' },
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const columns2 = computed(() => {
return [
{
key: 'name',
label: 'Name',
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'price', label: 'Price' },
{ key: 'created_at', label: 'Created At' },
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await itemStore.fetchItems(data)
return {
data: response.data.items.data,
pagination: {
totalPages: response.data.items.last_page,
currentPage: page,
totalCount: response.data.itemTotalCount,
limit: 10,
},
}
}
return {
data,
columns,
fetchData,
columns2,
}
},
}
</script>

View File

@ -0,0 +1,92 @@
<template>
<form id="loginForm" @submit.prevent="onSubmit">
<BaseInputGroup
:error="v$.email.$error && v$.email.$errors[0].$message"
:label="$t('login.enter_email')"
class="mb-4"
required
>
<BaseInput
v-model="formData.email"
:invalid="v$.email.$error"
focus
type="email"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseButton
:loading="isLoading"
:disabled="isLoading"
type="submit"
variant="primary"
>
<div v-if="!isSent">
{{ $t('validation.send_reset_link') }}
</div>
<div v-else>
{{ $t('validation.not_yet') }}
</div>
</BaseButton>
<div class="mt-4 mb-4 text-sm">
<router-link
to="/login"
class="text-sm text-primary-400 hover:text-gray-700"
>
{{ $t('general.back_to_login') }}
</router-link>
</div>
</form>
</template>
<script type="text/babel" setup>
import axios from 'axios'
import { reactive, ref, computed } from 'vue'
import { required, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useI18n } from 'vue-i18n'
import { handleError } from '@/scripts/helpers/error-handling'
const notificationStore = useNotificationStore()
const { t } = useI18n()
const formData = reactive({
email: '',
})
const isSent = ref(false)
const isLoading = ref(false)
const rules = {
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
}
const v$ = useVuelidate(rules, formData)
async function onSubmit(e) {
v$.value.$touch()
if (!v$.value.$invalid) {
try {
isLoading.value = true
let res = await axios.post('/api/v1/auth/password/email', formData)
if (res.data) {
notificationStore.showNotification({
type: 'success',
message: 'Mail sent successfully',
})
}
isSent.value = true
isLoading.value = false
} catch (err) {
handleError(err)
isLoading.value = false
}
}
}
</script>

View File

@ -0,0 +1,129 @@
<template>
<form id="loginForm" class="mt-12 text-left" @submit.prevent="onSubmit">
<BaseInputGroup
:error="v$.email.$error && v$.email.$errors[0].$message"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.email"
:invalid="v$.email.$error"
focus
type="email"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="v$.password.$error && v$.password.$errors[0].$message"
:label="$t('login.password')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.password"
:invalid="v$.password.$error"
:type="getInputType"
name="password"
@input="v$.password.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/> </template
></BaseInput>
</BaseInputGroup>
<div class="mt-5 mb-8">
<div class="mb-4">
<router-link
to="forgot-password"
class="text-sm text-primary-400 hover:text-gray-700"
>
{{ $t('login.forgot_password') }}
</router-link>
</div>
</div>
<BaseButton :loading="isLoading" type="submit">
{{ $t('login.login') }}
</BaseButton>
</form>
</template>
<script setup>
import axios from 'axios'
import { ref, computed } from 'vue'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useRouter } from 'vue-router'
import { required, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/scripts/admin/stores/auth'
import { handleError } from '@/scripts/helpers/error-handling'
const notificationStore = useNotificationStore()
const authStore = useAuthStore()
const { t } = useI18n()
const router = useRouter()
const isLoading = ref(false)
let isShowPassword = ref(false)
const rules = {
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
required: helpers.withMessage(t('validation.required'), required),
},
}
const v$ = useVuelidate(
rules,
computed(() => authStore.loginData)
)
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
async function onSubmit() {
axios.defaults.withCredentials = true
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isLoading.value = true
try {
isLoading.value = true
await authStore.login(authStore.loginData)
router.push('/admin/dashboard')
notificationStore.showNotification({
type: 'success',
message: 'Logged in successfully.',
})
} catch (error) {
isLoading.value = false
}
}
</script>

View File

@ -0,0 +1,165 @@
<template>
<form id="loginForm" @submit.prevent="onSubmit">
<BaseInputGroup
:error="errorEmail"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="formData.email"
:invalid="v$.email.$error"
focus
type="email"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="errorPassword"
:label="$t('login.password')"
class="mb-4"
required
>
<BaseInput
v-model="formData.password"
:invalid="v$.password.$error"
type="password"
name="password"
@input="v$.password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="errorConfirmPassword"
:label="$t('login.retype_password')"
class="mb-4"
required
>
<BaseInput
v-model="formData.password_confirmation"
:invalid="v$.password_confirmation.$error"
type="password"
name="password"
@input="v$.password_confirmation.$touch()"
/>
</BaseInputGroup>
<BaseButton :loading="isLoading" type="submit" variant="primary">
{{ $t('login.reset_password') }}
</BaseButton>
</form>
</template>
<script type="text/babel" setup>
import { ref, computed, reactive } from 'vue'
import useVuelidate from '@vuelidate/core'
import { required, email, minLength, sameAs } from '@vuelidate/validators'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
import { useI18n } from 'vue-i18n'
import { handleError } from '@/scripts/helpers/error-handling'
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const formData = reactive({
email: '',
password: '',
password_confirmation: '',
})
const isLoading = ref(false)
const rules = computed(() => {
return {
email: { required, email },
password: {
required,
minLength: minLength(8),
},
password_confirmation: {
sameAsPassword: sameAs(formData.password),
},
}
})
const v$ = useVuelidate(rules, formData)
const errorEmail = computed(() => {
if (!v$.value.email.$error) {
return ''
}
if (v$.value.email.required.$invalid) {
return t('validation.required')
}
if (v$.value.email.email) {
return t('validation.email_incorrect')
}
return false
})
const errorPassword = computed(() => {
if (!v$.value.password.$error) {
return ''
}
if (v$.value.password.required.$invalid) {
return t('validation.required')
}
if (v$.value.password.minLength) {
return t('validation.password_min_length', {
count: v$.value.password.minLength.$params.min,
})
}
return false
})
const errorConfirmPassword = computed(() => {
if (!v$.value.password_confirmation.$error) {
return ''
}
if (v$.value.password_confirmation.sameAsPassword.$invalid) {
return t('validation.password_incorrect')
}
return false
})
async function onSubmit(e) {
v$.value.$touch()
if (!v$.value.$invalid) {
try {
let data = {
email: formData.email,
password: formData.password,
password_confirmation: formData.password_confirmation,
token: route.params.token,
}
isLoading.value = true
let res = await axios.post('/api/v1/auth/reset/password', data)
isLoading.value = false
if (res.data) {
notificationStore.showNotification({
type: 'success',
message: t('login.password_reset_successfully'),
})
router.push('/login')
}
} catch (err) {
handleError(err)
isLoading.value = false
if (err.response && err.response.status === 403) {
// notificationStore.showNotification({
// type: 'error',
// message: t('validation.email_incorrect'),
// })
}
}
}
}
</script>

View File

@ -0,0 +1,756 @@
<template>
<BasePage>
<form @submit.prevent="submitCustomerData">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$tc('customers.customer', 2)"
to="/admin/customers"
/>
<BaseBreadcrumb-item :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end">
<BaseButton type="submit" :loading="isSaving" :disabled="isSaving">
<template #left="slotProps">
<BaseIcon name="SaveIcon" :class="slotProps.class" />
</template>
{{
isEdit
? $t('customers.update_customer')
: $t('customers.save_customer')
}}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseCard class="mt-5">
<!-- Basic Info -->
<div class="grid grid-cols-5 gap-4 mb-8">
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.basic_info') }}
</h6>
<BaseInputGrid class="col-span-5 lg:col-span-4">
<BaseInputGroup
:label="$t('customers.display_name')"
required
:error="
v$.currentCustomer.name.$error &&
v$.currentCustomer.name.$errors[0].$message
"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
class=""
:invalid="v$.currentCustomer.name.$error"
@input="v$.currentCustomer.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.primary_contact_name')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.contact_name"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.currentCustomer.email.$error &&
v$.currentCustomer.email.$errors[0].$message
"
:content-loading="isFetchingInitialData"
:label="$t('customers.email')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.email"
:content-loading="isFetchingInitialData"
type="text"
name="email"
:invalid="v$.currentCustomer.email.$error"
@input="v$.currentCustomer.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.phone')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.phone"
:content-loading="isFetchingInitialData"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.primary_currency')"
:content-loading="isFetchingInitialData"
:error="
v$.currentCustomer.currency_id.$error &&
v$.currentCustomer.currency_id.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="customerStore.currentCustomer.currency_id"
value-prop="id"
label="name"
track-by="name"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
searchable
:can-deselect="false"
:placeholder="$t('customers.select_currency')"
:invalid="v$.currentCustomer.currency_id.$error"
class="w-full"
>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.currentCustomer.website.$error &&
v$.currentCustomer.website.$errors[0].$message
"
:label="$t('customers.website')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.website"
:content-loading="isFetchingInitialData"
type="url"
@input="v$.currentCustomer.website.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.prefix')"
:error="
v$.currentCustomer.prefix.$error &&
v$.currentCustomer.prefix.$errors[0].$message
"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.prefix"
:content-loading="isFetchingInitialData"
type="text"
name="name"
class=""
:invalid="v$.currentCustomer.prefix.$error"
@input="v$.currentCustomer.prefix.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<BaseDivider class="mb-5 md:mb-8" />
<!-- Portal Access-->
<div class="grid grid-cols-5 gap-4 mb-8">
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.portal_access') }}
</h6>
<BaseInputGrid class="col-span-5 lg:col-span-4">
<div class="md:col-span-2">
<p class="text-sm text-gray-500">
{{ $t('customers.portal_access_text') }}
</p>
<BaseSwitch
v-model="customerStore.currentCustomer.enable_portal"
class="mt-1 flex"
/>
</div>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:content-loading="isFetchingInitialData"
:label="$t('customers.portal_access_url')"
class="md:col-span-2"
:help-text="$t('customers.portal_access_url_help')"
>
<CopyInputField :token="getCustomerPortalUrl" />
</BaseInputGroup>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:content-loading="isFetchingInitialData"
:error="
v$.currentCustomer.password.$error &&
v$.currentCustomer.password.$errors[0].$message
"
:label="$t('customers.password')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.password"
:content-loading="isFetchingInitialData"
:type="isShowPassword ? 'text' : 'password'"
name="password"
:invalid="v$.currentCustomer.password.$error"
@input="v$.currentCustomer.password.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/> </template
></BaseInput>
</BaseInputGroup>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:error="
v$.currentCustomer.confirm_password.$error &&
v$.currentCustomer.confirm_password.$errors[0].$message
"
:content-loading="isFetchingInitialData"
label="Confirm Password"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.confirm_password"
:content-loading="isFetchingInitialData"
:type="isShowConfirmPassword ? 'text' : 'password'"
name="confirm_password"
:invalid="v$.currentCustomer.confirm_password.$error"
@input="v$.currentCustomer.confirm_password.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowConfirmPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/> </template
></BaseInput>
</BaseInputGroup>
</BaseInputGrid>
</div>
<BaseDivider class="mb-5 md:mb-8" />
<!-- Billing Address -->
<div class="grid grid-cols-5 gap-4 mb-8">
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.billing_address') }}
</h6>
<BaseInputGrid
v-if="customerStore.currentCustomer.billing"
class="col-span-5 lg:col-span-4"
>
<BaseInputGroup
:label="$t('customers.name')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.name"
:content-loading="isFetchingInitialData"
type="text"
class="w-full"
name="address_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.country')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="customerStore.currentCustomer.billing.country_id"
value-prop="id"
label="name"
track-by="name"
resolve-on-load
searchable
:content-loading="isFetchingInitialData"
:options="globalStore.countries"
:placeholder="$t('general.select_country')"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.state')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.billing.state"
:content-loading="isFetchingInitialData"
name="billing.state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.city')"
>
<BaseInput
v-model="customerStore.currentCustomer.billing.city"
:content-loading="isFetchingInitialData"
name="billing.city"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
:error="
(v$.currentCustomer.billing.address_street_1.$error &&
v$.currentCustomer.billing.address_street_1.$errors[0]
.$message) ||
(v$.currentCustomer.billing.address_street_2.$error &&
v$.currentCustomer.billing.address_street_2.$errors[0]
.$message)
"
:content-loading="isFetchingInitialData"
>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.billing.address_street_1
"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.street_1')"
type="text"
name="billing_street1"
:container-class="`mt-3`"
@input="v$.currentCustomer.billing.address_street_1.$touch()"
/>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.billing.address_street_2
"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.street_2')"
type="text"
class="mt-3"
name="billing_street2"
:container-class="`mt-3`"
@input="v$.currentCustomer.billing.address_street_2.$touch()"
/>
</BaseInputGroup>
<div class="space-y-6">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.phone')"
class="text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.phone"
:content-loading="isFetchingInitialData"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.zip_code')"
:content-loading="isFetchingInitialData"
class="mt-2 text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.zip"
:content-loading="isFetchingInitialData"
type="text"
name="zip"
/>
</BaseInputGroup>
</div>
</BaseInputGrid>
</div>
<BaseDivider class="mb-5 md:mb-8" />
<!-- Billing Address Copy Button -->
<div
class="flex items-center justify-start mb-6 md:justify-end md:mb-0"
>
<div class="p-1">
<BaseButton
type="button"
:content-loading="isFetchingInitialData"
size="sm"
variant="primary-outline"
@click="customerStore.copyAddress(true)"
>
<template #left="slotProps">
<BaseIcon
name="DocumentDuplicateIcon"
:class="slotProps.class"
/>
</template>
{{ $t('customers.copy_billing_address') }}
</BaseButton>
</div>
</div>
<!-- Shipping Address -->
<div
v-if="customerStore.currentCustomer.shipping"
class="grid grid-cols-5 gap-4 mb-8"
>
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.shipping_address') }}
</h6>
<BaseInputGrid class="col-span-5 lg:col-span-4">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.name')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.name"
:content-loading="isFetchingInitialData"
type="text"
name="address_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.country')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="customerStore.currentCustomer.shipping.country_id"
value-prop="id"
label="name"
track-by="name"
resolve-on-load
searchable
:content-loading="isFetchingInitialData"
:options="globalStore.countries"
:placeholder="$t('general.select_country')"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.state')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.shipping.state"
:content-loading="isFetchingInitialData"
name="shipping.state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.city')"
>
<BaseInput
v-model="customerStore.currentCustomer.shipping.city"
:content-loading="isFetchingInitialData"
name="shipping.city"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
:content-loading="isFetchingInitialData"
:error="
(v$.currentCustomer.shipping.address_street_1.$error &&
v$.currentCustomer.shipping.address_street_1.$errors[0]
.$message) ||
(v$.currentCustomer.shipping.address_street_2.$error &&
v$.currentCustomer.shipping.address_street_2.$errors[0]
.$message)
"
>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.shipping.address_street_1
"
:content-loading="isFetchingInitialData"
type="text"
:placeholder="$t('general.street_1')"
name="shipping_street1"
@input="v$.currentCustomer.shipping.address_street_1.$touch()"
/>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.shipping.address_street_2
"
:content-loading="isFetchingInitialData"
type="text"
:placeholder="$t('general.street_2')"
name="shipping_street2"
class="mt-3"
:container-class="`mt-3`"
@input="v$.currentCustomer.shipping.address_street_2.$touch()"
/>
</BaseInputGroup>
<div class="space-y-6">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.phone')"
class="text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.phone"
:content-loading="isFetchingInitialData"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.zip_code')"
:content-loading="isFetchingInitialData"
class="mt-2 text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.zip"
:content-loading="isFetchingInitialData"
type="text"
name="zip"
/>
</BaseInputGroup>
</div>
</BaseInputGrid>
</div>
<BaseDivider
v-if="customFieldStore.customFields.length > 0"
class="mb-5 md:mb-8"
/>
<!-- Customer Custom Fields -->
<div class="grid grid-cols-5 gap-2 mb-8">
<h6
v-if="customFieldStore.customFields.length > 0"
class="col-span-5 text-lg font-semibold text-left lg:col-span-1"
>
{{ $t('settings.custom_fields.title') }}
</h6>
<div class="col-span-5 lg:col-span-4">
<CustomerCustomFields
type="Customer"
:store="customerStore"
store-prop="currentCustomer"
:is-edit="isEdit"
:is-loading="isLoadingContent"
:custom-field-scope="customFieldValidationScope"
/>
</div>
</div>
</BaseCard>
</form>
</BasePage>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
url,
maxLength,
helpers,
email,
sameAs,
requiredIf,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import CustomerCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import CopyInputField from '@/scripts/admin/components/CopyInputField.vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const customerStore = useCustomerStore()
const customFieldStore = useCustomFieldStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const customFieldValidationScope = 'customFields'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
let isFetchingInitialData = ref(false)
let isShowPassword = ref(false)
let isShowConfirmPassword = ref(false)
let active = ref(false)
const isSaving = ref(false)
const isEdit = computed(() => route.name === 'customers.edit')
let isLoadingContent = computed(() => customerStore.isFetchingInitialSettings)
const pageTitle = computed(() =>
isEdit.value ? t('customers.edit_customer') : t('customers.new_customer')
)
const rules = computed(() => {
return {
currentCustomer: {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
prefix: {
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
currency_id: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(customerStore.currentCustomer.enable_portal == true)
),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(
customerStore.currentCustomer.enable_portal == true &&
!customerStore.currentCustomer.password_added
)
),
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(customerStore.currentCustomer.password)
),
},
website: {
url: helpers.withMessage(t('validation.invalid_url'), url),
},
billing: {
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
address_street_2: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
},
shipping: {
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
address_street_2: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
},
},
}
})
const getCustomerPortalUrl = computed(() => {
return `${window.location.origin}/${companyStore.selectedCompany.slug}/customer/login`
})
const v$ = useVuelidate(rules, customerStore, {
$scope: customFieldValidationScope,
})
customerStore.resetCurrentCustomer()
customerStore.fetchCustomerInitialSettings(isEdit.value)
async function submitCustomerData() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
let data = {
...customerStore.currentCustomer,
}
let response = null
try {
const action = isEdit.value
? customerStore.updateCustomer
: customerStore.addCustomer
response = await action(data)
} catch (err) {
isSaving.value = false
return
}
router.push(`/admin/customers/${response.data.data.id}/view`)
}
</script>

View File

@ -0,0 +1,368 @@
<template>
<BasePage>
<!-- Page Header Section -->
<BasePageHeader :title="$t('customers.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$tc('customers.customer', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton
v-show="customerStore.totalCustomers"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FilterIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
@click="$router.push('customers/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('customers.new_customer') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-5" @clear="clearFilter">
<BaseInputGroup :label="$t('customers.display_name')" class="text-left">
<BaseInput
v-model="filters.display_name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.contact_name')" class="text-left">
<BaseInput
v-model="filters.contact_name"
type="text"
name="address_name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.phone')" class="text-left">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('customers.no_customers')"
:description="$t('customers.list_of_customers')"
>
<AstronautIcon class="mt-5 mb-4" />
<template #actions>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
variant="primary-outline"
@click="$router.push('/admin/customers/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('customers.add_new_customer') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<!-- Total no of Customers in Table -->
<div v-show="!showEmptyScreen" class="relative table-container">
<div class="relative flex items-center justify-end h-5">
<BaseDropdown v-if="customerStore.selectedCustomers.length">
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleCustomers">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<!-- Table Section -->
<BaseTable
ref="tableComponent"
class="mt-3"
:data="fetchData"
:columns="customerColumns"
>
<!-- Select All Checkbox -->
<template #header>
<div class="absolute z-10 items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="selectAllFieldStatus"
variant="primary"
@change="customerStore.selectAllCustomers"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.data.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-name="{ row }">
<router-link :to="{ path: `customers/${row.data.id}/view` }">
<BaseText
:text="row.data.name"
:length="30"
tag="span"
class="font-medium text-primary-500 flex flex-col"
/>
<BaseText
:text="row.data.contact_name ? row.data.contact_name : ''"
:length="30"
tag="span"
class="text-xs text-gray-400"
/>
</router-link>
</template>
<template #cell-phone="{ row }">
<span>
{{ row.data.phone ? row.data.phone : '-' }}
</span>
</template>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.due_amount || 0"
:currency="row.data.currency"
/>
</template>
<template #cell-created_at="{ row }">
<span>{{ row.data.formatted_created_at }}</span>
</template>
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<CustomerDropdown
:row="row.data"
:table="tableComponent"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { debouncedWatch } from '@vueuse/core'
import moment from 'moment'
import { reactive, ref, inject, computed, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import CustomerDropdown from '@/scripts/admin/components/dropdowns/CustomerIndexDropdown.vue'
import AstronautIcon from '@/scripts/components/icons/empty/AstronautIcon.vue'
const companyStore = useCompanyStore()
const dialogStore = useDialogStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
let tableComponent = ref(null)
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
const { t } = useI18n()
let filters = reactive({
display_name: '',
contact_name: '',
phone: '',
})
const showEmptyScreen = computed(
() => !customerStore.totalCustomers && !isFetchingInitialData.value
)
const selectField = computed({
get: () => customerStore.selectedCustomers,
set: (value) => {
return customerStore.selectCustomer(value)
},
})
const selectAllFieldStatus = computed({
get: () => customerStore.selectAllField,
set: (value) => {
return customerStore.setSelectAllState(value)
},
})
const customerColumns = computed(() => {
return [
{
key: 'status',
thClass: 'extra w-10 pr-0',
sortable: false,
tdClass: 'font-medium text-gray-900 pr-0',
},
{
key: 'name',
label: t('customers.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'phone', label: t('customers.phone') },
{ key: 'due_amount', label: t('customers.amount_due') },
{
key: 'created_at',
label: t('items.added_on'),
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium pl-0',
thClass: 'pl-0',
sortable: false,
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (customerStore.selectAllField) {
customerStore.selectAllCustomers()
}
})
function refreshTable() {
tableComponent.value.refresh()
}
function setFilters() {
refreshTable()
}
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_CUSTOMER,
abilities.EDIT_CUSTOMER,
abilities.VIEW_CUSTOMER,
])
}
async function fetchData({ page, filter, sort }) {
let data = {
display_name: filters.display_name,
contact_name: filters.contact_name,
phone: filters.phone,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await customerStore.fetchCustomers(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function clearFilter() {
filters.display_name = ''
filters.contact_name = ''
filters.phone = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
let date = ref(new Date())
date.value = moment(date).format('YYYY-MM-DD')
function removeMultipleCustomers() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('customers.confirm_delete', 2),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
customerStore.deleteMultipleCustomers().then((response) => {
if (response.data) {
refreshTable()
}
})
}
})
}
</script>

View File

@ -0,0 +1,144 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<router-link
v-if="userStore.hasAbilities(abilities.EDIT_CUSTOMER)"
:to="`/admin/customers/${route.params.id}/edit`"
>
<BaseButton
class="mr-3"
variant="primary-outline"
:content-loading="isLoading"
>
{{ $t('general.edit') }}
</BaseButton>
</router-link>
<BaseDropdown
v-if="canCreateTransaction()"
position="bottom-end"
:content-loading="isLoading"
>
<template #activator>
<BaseButton
class="mr-3"
variant="primary"
:content-loading="isLoading"
>
{{ $t('customers.new_transaction') }}
</BaseButton>
</template>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_ESTIMATE)"
:to="`/admin/estimates/create?customer=${$route.params.id}`"
>
<BaseDropdownItem class="">
<BaseIcon name="DocumentIcon" class="mr-3 text-gray-600" />
{{ $t('estimates.new_estimate') }}
</BaseDropdownItem>
</router-link>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_INVOICE)"
:to="`/admin/invoices/create?customer=${$route.params.id}`"
>
<BaseDropdownItem>
<BaseIcon name="DocumentTextIcon" class="mr-3 text-gray-600" />
{{ $t('invoices.new_invoice') }}
</BaseDropdownItem>
</router-link>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_PAYMENT)"
:to="`/admin/payments/create?customer=${$route.params.id}`"
>
<BaseDropdownItem>
<BaseIcon name="CreditCardIcon" class="mr-3 text-gray-600" />
{{ $t('payments.new_payment') }}
</BaseDropdownItem>
</router-link>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_EXPENSE)"
:to="`/admin/expenses/create?customer=${$route.params.id}`"
>
<BaseDropdownItem>
<BaseIcon name="CalculatorIcon" class="mr-3 text-gray-600" />
{{ $t('expenses.new_expense') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
<CustomerDropdown
v-if="hasAtleastOneAbility()"
:class="{
'ml-3': isLoading,
}"
:row="customerStore.selectedViewCustomer"
:load-data="refreshData"
/>
</template>
</BasePageHeader>
<!-- Customer View Sidebar -->
<CustomerViewSidebar />
<!-- Chart -->
<CustomerChart />
</BasePage>
</template>
<script setup>
import CustomerViewSidebar from './partials/CustomerViewSidebar.vue'
import CustomerChart from './partials/CustomerChart.vue'
import { ref, computed, inject } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useUserStore } from '@/scripts/admin/stores/user'
import CustomerDropdown from '@/scripts/admin/components/dropdowns/CustomerIndexDropdown.vue'
import abilities from '@/scripts/admin/stub/abilities'
const utils = inject('utils')
const dialogStore = useDialogStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const customer = ref(null)
const pageTitle = computed(() => {
return customerStore.selectedViewCustomer.customer
? customerStore.selectedViewCustomer.customer.name
: ''
})
let isLoading = computed(() => {
return customerStore.isFetchingViewData
})
function canCreateTransaction() {
return userStore.hasAbilities([
abilities.CREATE_ESTIMATE,
abilities.CREATE_INVOICE,
abilities.CREATE_PAYMENT,
abilities.CREATE_EXPENSE,
])
}
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_CUSTOMER,
abilities.EDIT_CUSTOMER,
])
}
function refreshData() {
router.push('/admin/customers')
}
</script>

View File

@ -0,0 +1,222 @@
<template>
<BaseCard class="flex flex-col mt-6">
<ChartPlaceholder v-if="customerStore.isFetchingViewData" />
<div v-else class="grid grid-cols-12">
<div class="col-span-12 xl:col-span-9 xxl:col-span-10">
<div class="flex justify-between mt-1 mb-6">
<h6 class="flex items-center">
<BaseIcon name="ChartSquareBarIcon" class="h-5 text-primary-400" />
{{ $t('dashboard.monthly_chart.title') }}
</h6>
<div class="w-40 h-10">
<BaseMultiselect
v-model="selectedYear"
:options="years"
:allow-empty="false"
:show-labels="false"
:placeholder="$t('dashboard.select_year')"
:can-deselect="false"
@select="onChangeYear"
/>
</div>
</div>
<LineChart
v-if="isLoading"
:invoices="getChartInvoices"
:expenses="getChartExpenses"
:receipts="getReceiptTotals"
:income="getNetProfits"
:labels="getChartMonths"
class="sm:w-full"
/>
</div>
<div
class="
grid
col-span-12
mt-6
text-center
xl:mt-0
sm:grid-cols-4
xl:text-right xl:col-span-3 xl:grid-cols-1
xxl:col-span-2
"
>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_sales') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
>
<BaseFormatMoney
:amount="chartData.salesTotal"
:currency="data.currency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_receipts') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #00c99c"
>
<BaseFormatMoney
:amount="chartData.totalExpenses"
:currency="data.currency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_expense') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #fb7178"
>
<BaseFormatMoney
:amount="chartData.totalExpenses"
:currency="data.currency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.net_income') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #5851d8"
>
<BaseFormatMoney
:amount="chartData.netProfit"
:currency="data.currency"
/>
</span>
</div>
</div>
</div>
<CustomerInfo />
</BaseCard>
</template>
<script setup>
import CustomerInfo from './CustomerInfo.vue'
import LineChart from '@/scripts/admin/components/charts/LineChart.vue'
import { ref, computed, watch, reactive, inject } from 'vue'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useRoute } from 'vue-router'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import ChartPlaceholder from './CustomerChartPlaceholder.vue'
const companyStore = useCompanyStore()
const customerStore = useCustomerStore()
const utils = inject('utils')
const route = useRoute()
let isLoading = ref(false)
let chartData = reactive({})
let data = reactive({})
let years = reactive(['This year', 'Previous year'])
let selectedYear = ref('This year')
const getChartExpenses = computed(() => {
if (chartData.expenseTotals) {
return chartData.expenseTotals
}
return []
})
const getNetProfits = computed(() => {
if (chartData.netProfits) {
return chartData.netProfits
}
return []
})
const getChartMonths = computed(() => {
if (chartData && chartData.months) {
return chartData.months
}
return []
})
const getReceiptTotals = computed(() => {
if (chartData.receiptTotals) {
return chartData.receiptTotals
}
return []
})
const getChartInvoices = computed(() => {
if (chartData.invoiceTotals) {
return chartData.invoiceTotals
}
return []
})
watch(
route,
() => {
if (route.params.id) {
loadCustomer()
}
selectedYear.value = 'This year'
},
{ immediate: true }
)
async function loadCustomer() {
isLoading.value = false
let response = await customerStore.fetchViewCustomer({
id: route.params.id,
})
if (response.data) {
Object.assign(chartData, response.data.meta.chartData)
Object.assign(data, response.data.data)
}
isLoading.value = true
}
async function onChangeYear(data) {
let params = {
id: route.params.id,
}
data === 'Previous year'
? (params.previous_year = true)
: (params.this_year = true)
let response = await customerStore.fetchViewCustomer(params)
if (response.data.meta.chartData) {
Object.assign(chartData, response.data.meta.chartData)
}
return true
}
</script>

View File

@ -0,0 +1,79 @@
<template>
<BaseContentPlaceholders class="grid grid-cols-12">
<div class="col-span-12 xl:col-span-9 xxl:col-span-10">
<div class="flex justify-between mt-1 mb-6">
<BaseContentPlaceholdersText class="h-10 w-36" :lines="1" />
<BaseContentPlaceholdersText class="h-10 w-40 !mt-0" :lines="1" />
</div>
<BaseContentPlaceholdersBox class="h-80 xl:h-72 sm:w-full" />
</div>
<div
class="
grid
col-span-12
mt-6
text-center
xl:mt-0
sm:grid-cols-4
xl:text-right xl:col-span-3 xl:grid-cols-1
xxl:col-span-2
"
>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
</div>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,119 @@
<template>
<div class="pt-6 mt-5 border-t border-solid lg:pt-8 md:pt-4 border-gray-200">
<!-- Basic Info -->
<BaseHeading>
{{ $t('customers.basic_info') }}
</BaseHeading>
<BaseDescriptionList>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.display_name')"
:value="selectedViewCustomer?.name"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.primary_contact_name')"
:value="selectedViewCustomer?.contact_name"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.email')"
:value="selectedViewCustomer?.email"
/>
</BaseDescriptionList>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('wizard.currency')"
:value="
selectedViewCustomer?.currency
? `${selectedViewCustomer?.currency?.code} (${selectedViewCustomer?.currency?.symbol})`
: ''
"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.phone_number')"
:value="selectedViewCustomer?.phone"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.website')"
:value="selectedViewCustomer?.website"
/>
</BaseDescriptionList>
<!-- Address -->
<BaseHeading
v-if="selectedViewCustomer.billing || selectedViewCustomer.shipping"
class="mt-8"
>
{{ $t('customers.address') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
v-if="selectedViewCustomer.billing"
:content-loading="contentLoading"
:label="$t('customers.billing_address')"
>
<BaseCustomerAddressDisplay :address="selectedViewCustomer.billing" />
</BaseDescriptionListItem>
<BaseDescriptionListItem
v-if="selectedViewCustomer.shipping"
:content-loading="contentLoading"
:label="$t('customers.shipping_address')"
>
<BaseCustomerAddressDisplay :address="selectedViewCustomer.shipping" />
</BaseDescriptionListItem>
</BaseDescriptionList>
<!-- Custom Fields -->
<BaseHeading v-if="customerCustomFields.length > 0" class="mt-8">
{{ $t('settings.custom_fields.title') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
v-for="(field, index) in customerCustomFields"
:key="index"
:content-loading="contentLoading"
:label="field.custom_field.label"
>
<p
v-if="field.type === 'Switch'"
class="text-sm font-bold leading-5 text-black non-italic"
>
<span v-if="field.default_answer === 1"> Yes </span>
<span v-else> No </span>
</p>
<p v-else class="text-sm font-bold leading-5 text-black non-italic">
{{ field.default_answer }}
</p>
</BaseDescriptionListItem>
</BaseDescriptionList>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
const customerStore = useCustomerStore()
const selectedViewCustomer = computed(() => customerStore.selectedViewCustomer)
const contentLoading = computed(() => customerStore.isFetchingViewData)
const customerCustomFields = computed(() => {
if (selectedViewCustomer?.value?.fields) {
return selectedViewCustomer?.value?.fields
}
return []
})
</script>

View File

@ -0,0 +1,294 @@
<template>
<div
class="
fixed
top-0
left-0
hidden
h-full
pt-16
pb-4
ml-56
bg-white
xl:ml-64
w-88
xl:block
"
>
<div
class="
flex
items-center
justify-between
px-4
pt-8
pb-2
border border-gray-200 border-solid
height-full
"
>
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
container-class="mb-6"
type="text"
variant="gray"
@input="onSearch()"
>
<BaseIcon name="SearchIcon" class="text-gray-500" />
</BaseInput>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<BaseDropdown
:close-on-select="false"
position="bottom-start"
width-class="w-40"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FilterIcon" />
</BaseButton>
</template>
<div
class="
px-4
py-3
pb-2
mb-2
text-sm
border-b border-gray-200 border-solid
"
>
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem
class="flex px-1 py-2 mt-1 cursor-pointer hover:rounded-md"
>
<BaseInputGroup class="pt-2 -mt-4">
<BaseRadio
id="filter_create_date"
v-model="searchData.orderByField"
:label="$t('customers.create_date')"
size="sm"
name="filter"
value="invoices.created_at"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="flex px-1 cursor-pointer hover:rounded-md">
<BaseInputGroup class="pt-2 -mt-4">
<BaseRadio
id="filter_display_name"
v-model="searchData.orderByField"
:label="$t('customers.display_name')"
size="sm"
name="filter"
value="name"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" />
<BaseIcon v-else name="SortDescendingIcon" />
</BaseButton>
</div>
</div>
<div
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
sidebar
base-scroll
"
>
<div v-for="(customer, index) in customerStore.customers" :key="index">
<router-link
v-if="customer && !isFetching"
:id="'customer-' + customer.id"
:to="`/admin/customers/${customer.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-gray-100 border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid':
hasActiveUrl(customer.id),
},
]"
style="border-top: 1px solid rgba(185, 193, 209, 0.41)"
>
<div>
<BaseText
:text="customer.name"
:length="30"
class="
pr-2
text-sm
not-italic
font-normal
leading-5
text-black
capitalize
truncate
"
/>
<BaseText
v-if="customer.contact_name"
:text="customer.contact_name"
:length="30"
class="
mt-1
text-xs
not-italic
font-medium
leading-5
text-gray-600
"
/>
</div>
<div class="flex-1 font-bold text-right whitespace-nowrap">
<BaseFormatMoney
:amount="customer.due_amount"
:currency="customer.currency"
/>
</div>
</router-link>
</div>
<div class="flex justify-center p-4 items-center">
<LoadingIcon
v-if="isFetching"
class="h-6 m-1 animate-spin text-primary-400"
/>
</div>
<p
v-if="!customerStore.customers.length && !isFetching"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('customers.no_matching_customers') }}
</p>
</div>
</div>
</template>
<script setup>
import { computed, ref, reactive, watch, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import { debounce } from 'lodash'
const customerStore = useCustomerStore()
const title = 'Customer View'
const route = useRoute()
const { t } = useI18n()
let isSearching = ref(false)
let isFetching = ref(false)
let searchData = reactive({
orderBy: '',
orderByField: '',
searchText: '',
})
onSearch = debounce(onSearch, 500)
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() =>
getOrderBy.value ? t('general.ascending') : t('general.descending')
)
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadCustomers() {
isFetching.value = true
await customerStore.fetchCustomers({ limit: 'all' })
isFetching.value = false
setTimeout(() => {
scrollToCustomer()
}, 500)
}
function scrollToCustomer() {
const el = document.getElementById(`customer-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch() {
let data = {}
if (
searchData.searchText !== '' &&
searchData.searchText !== null &&
searchData.searchText !== undefined
) {
data.display_name = searchData.searchText
}
if (searchData.orderBy !== null && searchData.orderBy !== undefined) {
data.orderBy = searchData.orderBy
}
if (
searchData.orderByField !== null &&
searchData.orderByField !== undefined
) {
data.orderByField = searchData.orderByField
}
isSearching.value = true
try {
let response = await customerStore.fetchCustomers(data)
isSearching.value = false
if (response.data) {
customerStore.customers = response.data.data
}
} catch (error) {
isSearching.value = false
}
}
function sortData() {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
onSearch()
return true
}
searchData.orderBy = 'asc'
onSearch()
return true
}
loadCustomers()
</script>

View File

@ -0,0 +1,28 @@
<script setup>
import DashboardStats from '../dashboard/DashboardStats.vue'
import DashboardChart from '../dashboard/DashboardChart.vue'
import DashboardTable from '../dashboard/DashboardTable.vue'
import { useUserStore } from '@/scripts/admin/stores/user'
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const userStore = useUserStore()
const router = useRouter()
onMounted(() => {
if (route.meta.ability && !userStore.hasAbilities(route.meta.ability)) {
router.push({ name: 'account.settings' })
} else if (route.meta.isOwner && !userStore.currentUser.is_owner) {
router.push({ name: 'account.settings' })
}
})
</script>
<template>
<BasePage>
<DashboardStats />
<DashboardChart />
<DashboardTable />
</BasePage>
</template>

View File

@ -0,0 +1,186 @@
<template>
<div>
<div
v-if="dashboardStore.isDashboardDataLoaded"
class="grid grid-cols-10 mt-8 bg-white rounded shadow"
>
<!-- Chart -->
<div
class="
grid grid-cols-1
col-span-10
px-4
py-5
lg:col-span-7
xl:col-span-8
sm:p-6
"
>
<div class="flex justify-between mt-1 mb-4 flex-col md:flex-row">
<h6 class="flex items-center sw-section-title h-10">
<BaseIcon name="ChartSquareBarIcon" class="text-primary-400 mr-1" />
{{ $t('dashboard.monthly_chart.title') }}
</h6>
<div class="w-full my-2 md:m-0 md:w-40 h-10">
<BaseMultiselect
v-model="selectedYear"
:options="years"
:allow-empty="false"
:show-labels="false"
:placeholder="$t('dashboard.select_year')"
:can-deselect="false"
/>
</div>
</div>
<LineChart
:invoices="dashboardStore.chartData.invoiceTotals"
:expenses="dashboardStore.chartData.expenseTotals"
:receipts="dashboardStore.chartData.receiptTotals"
:income="dashboardStore.chartData.netIncomeTotals"
:labels="dashboardStore.chartData.months"
class="sm:w-full"
/>
</div>
<!-- Chart Labels -->
<div
class="
grid grid-cols-3
col-span-10
text-center
border-t border-l border-gray-200 border-solid
lg:border-t-0 lg:text-right lg:col-span-3
xl:col-span-2
lg:grid-cols-1
"
>
<div class="p-6">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_sales') }}
</span>
<br />
<span class="block mt-1 text-xl font-semibold leading-8 lg:text-2xl">
<BaseFormatMoney
:amount="dashboardStore.totalSales"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
<div class="p-6">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_receipts') }}
</span>
<br />
<span
class="
block
mt-1
text-xl
font-semibold
leading-8
lg:text-2xl
text-green-400
"
>
<BaseFormatMoney
:amount="dashboardStore.totalReceipts"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
<div class="p-6">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_expense') }}
</span>
<br />
<span
class="
block
mt-1
text-xl
font-semibold
leading-8
lg:text-2xl
text-red-400
"
>
<BaseFormatMoney
:amount="dashboardStore.totalExpenses"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
<div
class="
col-span-3
p-6
border-t border-gray-200 border-solid
lg:col-span-1
"
>
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.net_income') }}
</span>
<br />
<span
class="
block
mt-1
text-xl
font-semibold
leading-8
lg:text-2xl
text-primary-500
"
>
<BaseFormatMoney
:amount="dashboardStore.totalNetIncome"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
</div>
</div>
<ChartPlaceholder v-else />
</div>
</template>
<script setup>
import { ref, watch, inject } from 'vue'
import { useDashboardStore } from '@/scripts/admin/stores/dashboard'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import LineChart from '@/scripts/admin/components/charts/LineChart.vue'
import ChartPlaceholder from './DashboardChartPlaceholder.vue'
import abilities from '@/scripts/admin/stub/abilities'
import { useUserStore } from '@/scripts/admin/stores/user'
const dashboardStore = useDashboardStore()
const companyStore = useCompanyStore()
const utils = inject('utils')
const userStore = useUserStore()
const years = ref(['This year', 'Previous year'])
const selectedYear = ref('This year')
watch(
selectedYear,
(val) => {
if (val === 'Previous year') {
let params = { previous_year: true }
loadData(params)
} else {
loadData()
}
},
{ immediate: true }
)
async function loadData(params) {
if (userStore.hasAbilities(abilities.DASHBOARD)) {
await dashboardStore.loadData(params)
}
}
</script>

View File

@ -0,0 +1,88 @@
<template>
<BaseContentPlaceholders
class="grid grid-cols-10 mt-8 bg-white rounded shadow"
>
<!-- Chart -->
<div
class="
grid grid-cols-1
col-span-10
px-4
py-5
lg:col-span-7
xl:col-span-8
sm:p-8
"
>
<div class="flex items-center justify-between mb-2 xl:mb-4">
<BaseContentPlaceholdersText class="h-10 w-36" :lines="1" />
<BaseContentPlaceholdersText class="h-10 w-36 !mt-0" :lines="1" />
</div>
<BaseContentPlaceholdersBox class="h-80 xl:h-72 sm:w-full" />
</div>
<!-- Chart Labels -->
<div
class="
grid grid-cols-3
col-span-10
text-center
border-t border-l border-gray-200 border-solid
lg:border-t-0 lg:text-right lg:col-span-3
xl:col-span-2
lg:grid-cols-1
"
>
<div
class="
flex flex-col
items-center
justify-center
p-6
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
p-6
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
p-6
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
col-span-3
p-6
border-t border-gray-200 border-solid
lg:justify-end lg:items-end lg:col-span-1
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
</div>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,79 @@
<template>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-9 xl:gap-8">
<!-- Amount Due -->
<DashboardStatsItem
:icon-component="DollarIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
:route="
userStore.hasAbilities(abilities.VIEW_INVOICE) ? `/admin/invoices` : ''
"
:large="true"
:label="$t('dashboard.cards.due_amount')"
>
<BaseFormatMoney
:amount="dashboardStore.stats.totalAmountDue"
:currency="companyStore.selectedCompanyCurrency"
/>
</DashboardStatsItem>
<!-- Customers -->
<DashboardStatsItem
:icon-component="CustomerIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
:route="
userStore.hasAbilities(abilities.VIEW_CUSTOMER)
? `/admin/customers`
: ''
"
:label="$t('dashboard.cards.customers')"
>
{{ dashboardStore.stats.totalCustomerCount }}
</DashboardStatsItem>
<!-- Invoices -->
<DashboardStatsItem
:icon-component="InvoiceIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
:route="
userStore.hasAbilities(abilities.VIEW_INVOICE) ? `/admin/invoices` : ''
"
:label="$t('dashboard.cards.invoices')"
>
{{ dashboardStore.stats.totalInvoiceCount }}
</DashboardStatsItem>
<!-- Estimates -->
<DashboardStatsItem
:icon-component="EstimateIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
:route="
userStore.hasAbilities(abilities.VIEW_ESTIMATE)
? `/admin/estimates`
: ''
"
:label="$t('dashboard.cards.estimates')"
>
{{ dashboardStore.stats.totalEstimateCount }}
</DashboardStatsItem>
</div>
</template>
<script setup>
import DollarIcon from '@/scripts/components/icons/dashboard/DollarIcon.vue'
import CustomerIcon from '@/scripts/components/icons/dashboard/CustomerIcon.vue'
import InvoiceIcon from '@/scripts/components/icons/dashboard/InvoiceIcon.vue'
import EstimateIcon from '@/scripts/components/icons/dashboard/EstimateIcon.vue'
import abilities from '@/scripts/admin/stub/abilities'
import DashboardStatsItem from './DashboardStatsItem.vue'
import { inject } from 'vue'
import { useDashboardStore } from '@/scripts/admin/stores/dashboard'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
const utils = inject('utils')
const dashboardStore = useDashboardStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
</script>

View File

@ -0,0 +1,64 @@
<template>
<router-link
v-if="!loading"
class="
relative
flex
justify-between
p-3
bg-white
rounded
shadow
hover:bg-gray-50
xl:p-4
lg:col-span-2
"
:class="{ 'lg:!col-span-3': large }"
:to="route"
>
<div>
<span class="text-xl font-semibold leading-tight text-black xl:text-3xl">
<slot />
</span>
<span class="block mt-1 text-sm leading-tight text-gray-500 xl:text-lg">
{{ label }}
</span>
</div>
<div class="flex items-center">
<component :is="iconComponent" class="w-10 h-10 xl:w-12 xl:h-12" />
</div>
</router-link>
<StatsCardPlaceholder v-else-if="large" />
<StatsCardSmPlaceholder v-else />
</template>
<script setup>
import StatsCardPlaceholder from './DashboardStatsPlaceholder.vue'
import StatsCardSmPlaceholder from './DashboardStatsSmPlaceholder.vue'
defineProps({
iconComponent: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
route: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
large: {
type: Boolean,
default: false,
},
})
</script>

View File

@ -0,0 +1,20 @@
<template>
<BaseContentPlaceholders
:rounded="true"
class="relative flex justify-between w-full p-3 bg-white rounded shadow lg:col-span-3 xl:p-4"
>
<div>
<BaseContentPlaceholdersText
class="h-5 -mb-1 w-14 xl:mb-6 xl:h-7"
:lines="1"
/>
<BaseContentPlaceholdersText class="h-3 w-28 xl:h-4" :lines="1" />
</div>
<div class="flex items-center">
<BaseContentPlaceholdersBox
:circle="true"
class="w-10 h-10 xl:w-12 xl:h-12"
/>
</div>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,31 @@
<template>
<BaseContentPlaceholders
:rounded="true"
class="
relative
flex
justify-between
w-full
p-3
bg-white
rounded
shadow
lg:col-span-2
xl:p-4
"
>
<div>
<BaseContentPlaceholdersText
class="w-12 h-5 -mb-1 xl:mb-6 xl:h-7"
:lines="1"
/>
<BaseContentPlaceholdersText class="w-20 h-3 xl:h-4" :lines="1" />
</div>
<div class="flex items-center">
<BaseContentPlaceholdersBox
:circle="true"
class="w-10 h-10 xl:w-12 xl:h-12"
/>
</div>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,180 @@
<template>
<div>
<div class="grid grid-cols-1 gap-6 mt-10 xl:grid-cols-2">
<!-- Due Invoices -->
<div class="due-invoices">
<div class="relative z-10 flex items-center justify-between mb-3">
<h6 class="mb-0 text-xl font-semibold leading-normal">
{{ $t('dashboard.recent_invoices_card.title') }}
</h6>
<BaseButton
size="sm"
variant="primary-outline"
@click="$router.push('/admin/invoices')"
>
{{ $t('dashboard.recent_invoices_card.view_all') }}
</BaseButton>
</div>
<BaseTable
:data="dashboardStore.recentDueInvoices"
:columns="dueInvoiceColumns"
:loading="!dashboardStore.isDashboardDataLoaded"
>
<template #cell-user="{ row }">
<router-link
:to="{ path: `invoices/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.customer.name }}
</router-link>
</template>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.due_amount"
:currency="row.data.customer.currency"
/>
</template>
<!-- Actions -->
<template
v-if="hasAtleastOneInvoiceAbility()"
#cell-actions="{ row }"
>
<InvoiceDropdown :row="row.data" :table="invoiceTableComponent" />
</template>
</BaseTable>
</div>
<!-- Recent Estimates -->
<div class="recent-estimates">
<div class="relative z-10 flex items-center justify-between mb-3">
<h6 class="mb-0 text-xl font-semibold leading-normal">
{{ $t('dashboard.recent_estimate_card.title') }}
</h6>
<BaseButton
variant="primary-outline"
size="sm"
@click="$router.push('/admin/estimates')"
>
{{ $t('dashboard.recent_estimate_card.view_all') }}
</BaseButton>
</div>
<BaseTable
:data="dashboardStore.recentEstimates"
:columns="recentEstimateColumns"
:loading="!dashboardStore.isDashboardDataLoaded"
>
<template #cell-user="{ row }">
<router-link
:to="{ path: `estimates/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.customer.name }}
</router-link>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<template
v-if="hasAtleastOneEstimateAbility()"
#cell-actions="{ row }"
>
<EstimateDropdown :row="row" :table="estimateTableComponent" />
</template>
</BaseTable>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useDashboardStore } from '@/scripts/admin/stores/dashboard'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import InvoiceDropdown from '@/scripts/admin/components/dropdowns/InvoiceIndexDropdown.vue'
import EstimateDropdown from '@/scripts/admin/components/dropdowns/EstimateIndexDropdown.vue'
const dashboardStore = useDashboardStore()
const { t } = useI18n()
const userStore = useUserStore()
const invoiceTableComponent = ref(null)
const estimateTableComponent = ref(null)
const dueInvoiceColumns = computed(() => {
return [
{
key: 'formattedDueDate',
label: t('dashboard.recent_invoices_card.due_on'),
},
{
key: 'user',
label: t('dashboard.recent_invoices_card.customer'),
},
{
key: 'due_amount',
label: t('dashboard.recent_invoices_card.amount_due'),
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium pl-0',
thClass: 'text-right pl-0',
sortable: false,
},
]
})
const recentEstimateColumns = computed(() => {
return [
{
key: 'formattedEstimateDate',
label: t('dashboard.recent_estimate_card.date'),
},
{
key: 'user',
label: t('dashboard.recent_estimate_card.customer'),
},
{
key: 'total',
label: t('dashboard.recent_estimate_card.amount_due'),
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium pl-0',
thClass: 'text-right pl-0',
sortable: false,
},
]
})
function hasAtleastOneInvoiceAbility() {
return userStore.hasAbilities([
abilities.DELETE_INVOICE,
abilities.EDIT_INVOICE,
abilities.VIEW_INVOICE,
abilities.SEND_INVOICE,
])
}
function hasAtleastOneEstimateAbility() {
return userStore.hasAbilities([
abilities.CREATE_ESTIMATE,
abilities.EDIT_ESTIMATE,
abilities.VIEW_ESTIMATE,
abilities.SEND_ESTIMATE,
])
}
</script>

View File

@ -0,0 +1,64 @@
<template>
<div class="w-full h-screen h-screen-ios">
<div class="flex items-center justify-center w-full h-full">
<div class="flex flex-col items-center justify-center">
<h1 class="text-primary-500" style="font-size: 10rem">
{{ $t('general.four_zero_four') }}
</h1>
<h5 class="mb-10 text-3xl text-primary-500">
{{ $t('general.you_got_lost') }}
</h5>
<router-link
class="
flex
items-center
w-32
h-12
px-3
py-1
text-base
font-medium
leading-none
text-center text-white
rounded
whitespace-nowrap
bg-primary-500
btn-lg
hover:text-white
"
:to="path"
>
<BaseIcon name="ArrowLeftIcon" class="mr-2 text-white icon" />
{{ $t('general.go_home') }}
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const path = computed(() => {
let data = route.path.indexOf('customer')
if (data > -1 && route.params.company) {
return `/${route.params.company}/customer/dashboard`
} else if (route.params.catchAll) {
let index = route.params.catchAll.indexOf('/')
if (index > -1) {
let slug = route.params.catchAll.substring(index, 0)
return `/${slug}/customer/dashboard`
} else {
return '/'
}
} else {
return `/admin/dashboard`
}
})
</script>

View File

@ -0,0 +1,487 @@
<template>
<BasePage>
<SendEstimateModal />
<BasePageHeader :title="$t('estimates.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$tc('estimates.estimate', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="estimateStore.totalEstimateCount"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
:class="slotProps.class"
name="FilterIcon"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_ESTIMATE)"
to="estimates/create"
>
<BaseButton variant="primary" class="ml-4">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('estimates.new_estimate') }}
</BaseButton>
</router-link>
</template>
</BasePageHeader>
<BaseFilterWrapper
v-show="showFilters"
:row-on-xl="true"
@clear="clearFilter"
>
<BaseInputGroup :label="$tc('customers.customer', 1)">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('estimates.status')">
<BaseMultiselect
v-model="filters.status"
:options="status"
searchable
:placeholder="$t('general.select_a_status')"
@update:modelValue="setActiveTab"
@remove="clearStatusSearch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('general.to')">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('estimates.estimate_number')">
<BaseInput v-model="filters.estimate_number">
<template #left="slotProps">
<BaseIcon name="HashtagIcon" :class="slotProps.class" />
</template>
</BaseInput>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('estimates.no_estimates')"
:description="$t('estimates.list_of_estimates')"
>
<ObservatoryIcon class="mt-5 mb-4" />
<template #actions>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_ESTIMATE)"
variant="primary-outline"
@click="$router.push('/admin/estimates/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('estimates.add_new_estimate') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<div
class="
relative
flex
items-center
justify-between
h-10
mt-5
list-none
border-b-2 border-gray-200 border-solid
"
>
<!-- Tabs -->
<BaseTabGroup class="-mb-5" @change="setStatusFilter">
<BaseTab :title="$t('general.draft')" filter="DRAFT" />
<BaseTab :title="$t('general.sent')" filter="SENT" />
<BaseTab :title="$t('general.all')" filter="" />
</BaseTabGroup>
<BaseDropdown
v-if="
estimateStore.selectedEstimates.length &&
userStore.hasAbilities(abilities.DELETE_ESTIMATE)
"
class="absolute float-right"
>
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleEstimates">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableComponent"
:data="fetchData"
:columns="estimateColumns"
:placeholder-count="estimateStore.totalEstimateCount >= 20 ? 10 : 5"
class="mt-10"
>
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="estimateStore.selectAllField"
variant="primary"
@change="estimateStore.selectAllEstimates"
/>
</div>
</template>
<template #cell-checkbox="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
/>
</div>
</template>
<!-- Estimate date -->
<template #cell-estimate_date="{ row }">
{{ row.data.formatted_estimate_date }}
</template>
<template #cell-estimate_number="{ row }">
<router-link
:to="{ path: `estimates/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.estimate_number }}
</router-link>
</template>
<template #cell-name="{ row }">
<BaseText :text="row.data.customer.name" :length="30" />
</template>
<template #cell-status="{ row }">
<BaseEstimateStatusBadge :status="row.data.status" class="px-3 py-1">
{{ row.data.status }}
</BaseEstimateStatusBadge>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<!-- Actions -->
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<EstimateDropDown :row="row.data" :table="tableComponent" />
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useUserStore } from '@/scripts/admin/stores/user'
import { debouncedWatch } from '@vueuse/core'
import abilities from '@/scripts/admin/stub/abilities'
import ObservatoryIcon from '@/scripts/components/icons/empty/ObservatoryIcon.vue'
import EstimateDropDown from '@/scripts/admin/components/dropdowns/EstimateIndexDropdown.vue'
import SendEstimateModal from '@/scripts/admin/components/modal-components/SendEstimateModal.vue'
const estimateStore = useEstimateStore()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const tableComponent = ref(null)
const { t } = useI18n()
const showFilters = ref(false)
const status = ref([
'DRAFT',
'SENT',
'VIEWED',
'EXPIRED',
'ACCEPTED',
'REJECTED',
])
const isRequestOngoing = ref(true)
const activeTab = ref('general.draft')
const router = useRouter()
let filters = reactive({
customer_id: '',
status: 'DRAFT',
from_date: '',
to_date: '',
estimate_number: '',
})
const showEmptyScreen = computed(
() => !estimateStore.totalEstimateCount && !isRequestOngoing.value
)
const selectField = computed({
get: () => estimateStore.selectedEstimates,
set: (val) => {
estimateStore.selectEstimate(val)
},
})
const estimateColumns = computed(() => {
return [
{
key: 'checkbox',
thClass: 'extra w-10 pr-0',
sortable: false,
tdClass: 'font-medium text-gray-900 pr-0',
},
{
key: 'estimate_date',
label: t('estimates.date'),
thClass: 'extra',
tdClass: 'font-medium text-gray-500',
},
{ key: 'estimate_number', label: t('estimates.number', 2) },
{ key: 'name', label: t('estimates.customer') },
{ key: 'status', label: t('estimates.status') },
{
key: 'total',
label: t('estimates.total'),
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium pl-0',
thClass: 'text-right pl-0',
sortable: false,
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (estimateStore.selectAllField) {
estimateStore.selectAllEstimates()
}
})
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.CREATE_ESTIMATE,
abilities.EDIT_ESTIMATE,
abilities.VIEW_ESTIMATE,
abilities.SEND_ESTIMATE,
])
}
async function clearStatusSearch(removedOption, id) {
filters.status = ''
refreshTable()
}
function refreshTable() {
tableComponent.value && tableComponent.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
customer_id: filters.customer_id,
status: filters.status,
from_date: filters.from_date,
to_date: filters.to_date,
estimate_number: filters.estimate_number,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isRequestOngoing.value = true
let response = await estimateStore.fetchEstimates(data)
isRequestOngoing.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function setStatusFilter(val) {
if (activeTab.value == val.title) {
return true
}
activeTab.value = val.title
switch (val.title) {
case t('general.draft'):
filters.status = 'DRAFT'
break
case t('general.sent'):
filters.status = 'SENT'
break
default:
filters.status = ''
break
}
}
function setFilters() {
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
refreshTable()
}
function clearFilter() {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.estimate_number = ''
activeTab.value = t('general.all')
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function removeMultipleEstimates() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
estimateStore.deleteMultipleEstimates().then((res) => {
refreshTable()
if (res.data) {
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
}
})
}
})
}
function setActiveTab(val) {
switch (val) {
case 'DRAFT':
activeTab.value = t('general.draft')
break
case 'SENT':
activeTab.value = t('general.sent')
break
case 'VIEWED':
activeTab.value = t('estimates.viewed')
break
case 'EXPIRED':
activeTab.value = t('estimates.expired')
break
case 'ACCEPTED':
activeTab.value = t('estimates.accepted')
break
case 'REJECTED':
activeTab.value = t('estimates.rejected')
break
default:
activeTab.value = t('general.all')
break
}
}
</script>

View File

@ -0,0 +1,501 @@
<template>
<SendEstimateModal />
<BasePage v-if="estimateData" class="xl:pl-96 xl:ml-8">
<BasePageHeader :title="pageTitle">
<template #actions>
<div class="mr-3 text-sm">
<BaseButton
v-if="
estimateData.status === 'DRAFT' &&
userStore.hasAbilities(abilities.EDIT_ESTIMATE)
"
:disabled="isMarkAsSent"
:content-loading="isLoadingEstimate"
variant="primary-outline"
@click="onMarkAsSent"
>
{{ $t('estimates.mark_as_sent') }}
</BaseButton>
</div>
<BaseButton
v-if="
estimateData.status === 'DRAFT' &&
userStore.hasAbilities(abilities.SEND_ESTIMATE)
"
:disabled="isSendingEmail"
:content-loading="isLoadingEstimate"
variant="primary"
class="text-sm"
@click="onSendEstimate"
>
{{ $t('estimates.send_estimate') }}
</BaseButton>
<EstimateDropDown class="ml-3" :row="estimateData" />
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="
fixed
top-0
left-0
hidden
h-full
pt-16
pb-4
ml-56
bg-white
xl:ml-64
w-88
xl:block
"
>
<div
class="
flex
items-center
justify-between
px-4
pt-8
pb-2
border border-gray-200 border-solid
height-full
"
>
<div class="mb-6">
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearched()"
>
<template #right>
<BaseIcon name="SearchIcon" class="text-gray-400" />
</template>
</BaseInput>
</div>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<BaseDropdown
class="ml-3"
position="bottom-start"
width-class="w-45"
position-class="left-0"
>
<template #activator>
<BaseButton size="md" variant="gray">
<BaseIcon name="FilterIcon" />
</BaseButton>
</template>
<div
class="
px-4
py-1
pb-2
mb-1 mb-2
text-sm
border-b border-gray-200 border-solid
"
>
{{ $t('general.sort_by') }}
</div>
<BaseDropdownItem class="flex px-4 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_estimate_date"
v-model="searchData.orderByField"
:label="$t('reports.estimates.estimate_date')"
size="sm"
name="filter"
value="estimate_date"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
<BaseDropdownItem class="flex px-4 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_due_date"
v-model="searchData.orderByField"
:label="$t('estimates.due_date')"
value="expiry_date"
size="sm"
name="filter"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
<BaseDropdownItem class="flex px-4 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_estimate_number"
v-model="searchData.orderByField"
:label="$t('estimates.estimate_number')"
value="estimate_number"
size="sm"
name="filter"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" />
<BaseIcon v-else name="SortDescendingIcon" />
</BaseButton>
</div>
</div>
<div
v-if="estimateStore && estimateStore.estimates"
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
base-scroll
"
>
<div v-for="(estimate, index) in estimateStore.estimates" :key="index">
<router-link
v-if="estimate && !isLoading"
:id="'estimate-' + estimate.id"
:to="`/admin/estimates/${estimate.id}/view`"
:class="[
'flex justify-between side-estimate p-4 cursor-pointer hover:bg-gray-100 items-center border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid':
hasActiveUrl(estimate.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<BaseText
:text="estimate.customer.name"
:length="30"
class="
pr-2
mb-2
text-sm
not-italic
font-normal
leading-5
text-black
capitalize
truncate
"
/>
<div
class="
mt-1
mb-2
text-xs
not-italic
font-medium
leading-5
text-gray-600
"
>
{{ estimate.estimate_number }}
</div>
<BaseEstimateStatusBadge
:status="estimate.status"
class="px-1 text-xs"
>
{{ estimate.status }}
</BaseEstimateStatusBadge>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
:amount="estimate.total"
:currency="estimate.customer.currency"
class="
block
mb-2
text-xl
not-italic
font-semibold
leading-8
text-right text-gray-900
"
/>
<div
class="
text-sm
not-italic
font-normal
leading-5
text-right text-gray-600
est-date
"
>
{{ estimate.formatted_estimate_date }}
</div>
</div>
</router-link>
</div>
<div class="flex justify-center p-4 items-center">
<LoadingIcon
v-if="isLoading"
class="h-6 m-1 animate-spin text-primary-400"
/>
</div>
<p
v-if="!estimateStore.estimates.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('estimates.no_matching_estimates') }}
</p>
</div>
</div>
<div
class="flex flex-col min-h-0 mt-8 overflow-hidden"
style="height: 75vh"
>
<iframe
:src="`${shareableLink}`"
class="
flex-1
border border-gray-400 border-solid
rounded-md
bg-white
frame-style
"
/>
</div>
</BasePage>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, reactive, ref, watch, inject } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import EstimateDropDown from '@/scripts/admin/components/dropdowns/EstimateIndexDropdown.vue'
import { debounce } from 'lodash'
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import { useModalStore } from '@/scripts/stores/modal'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useUserStore } from '@/scripts/admin/stores/user'
import SendEstimateModal from '@/scripts/admin/components/modal-components/SendEstimateModal.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import abilities from '@/scripts/admin/stub/abilities'
const modalStore = useModalStore()
const estimateStore = useEstimateStore()
const notificationStore = useNotificationStore()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const { t } = useI18n()
const utils = inject('$utils')
const id = ref(null)
const count = ref(null)
const estimateData = ref(null)
const currency = ref(null)
const route = useRoute()
const router = useRouter()
const status = ref([
'DRAFT',
'SENT',
'VIEWED',
'EXPIRED',
'ACCEPTED',
'REJECTED',
])
const isMarkAsSent = ref(false)
const isSendingEmail = ref(false)
const isRequestOnGoing = ref(false)
const isSearching = ref(false)
const isLoading = ref(false)
const isLoadingEstimate = ref(false)
const searchData = reactive({
orderBy: null,
orderByField: null,
searchText: null,
})
const pageTitle = computed(() => estimateData.value.estimate_number)
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() => {
if (getOrderBy.value) {
return t('general.ascending')
}
return t('general.descending')
})
const shareableLink = computed(() => {
return `/estimates/pdf/${estimateData.value.unique_hash}`
})
const getCurrentEstimateId = computed(() => {
if (estimateData.value && estimateData.value.id) {
return estimate.value.id
}
return null
})
watch(route, (to, from) => {
if (to.name === 'estimates.view') {
loadEstimate()
}
})
loadEstimates()
loadEstimate()
onSearched = debounce(onSearched, 500)
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadEstimates() {
isLoading.value = true
await estimateStore.fetchEstimates(route.params.id)
isLoading.value = false
setTimeout(() => {
scrollToEstimate()
}, 500)
}
function scrollToEstimate() {
const el = document.getElementById(`estimate-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function loadEstimate() {
isLoadingEstimate.value = true
let response = await estimateStore.fetchEstimate(route.params.id)
if (response.data) {
isLoadingEstimate.value = false
estimateData.value = { ...response.data.data }
}
}
async function onSearched() {
let data = ''
if (
searchData.searchText !== '' &&
searchData.searchText !== null &&
searchData.searchText !== undefined
) {
data += `search=${searchData.searchText}&`
}
if (searchData.orderBy !== null && searchData.orderBy !== undefined) {
data += `orderBy=${searchData.orderBy}&`
}
if (
searchData.orderByField !== null &&
searchData.orderByField !== undefined
) {
data += `orderByField=${searchData.orderByField}`
}
isSearching.value = true
let response = await estimateStore.searchEstimate(data)
isSearching.value = false
if (response.data) {
estimateStore.estimates = response.data.data
}
}
function sortData() {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
onSearched()
return true
}
searchData.orderBy = 'asc'
onSearched()
return true
}
async function onMarkAsSent() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_mark_as_sent'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'primary',
hideNoButton: false,
size: 'lg',
})
.then((response) => {
isMarkAsSent.value = false
if (response) {
estimateStore.markAsSent({
id: estimateData.value.id,
status: 'SENT',
})
estimateData.value.status = 'SENT'
isMarkAsSent.value = true
}
isMarkAsSent.value = false
})
}
async function onSendEstimate(id) {
modalStore.openModal({
title: t('estimates.send_estimate'),
componentName: 'SendEstimateModal',
id: estimateData.value.id,
data: estimateData.value,
})
}
async function removeEstimate(id) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
estimateStore
.deleteEstimate({ ids: [id] })
.then(() => {
router.push('/admin/estimates')
})
.catch((err) => {
console.error(err)
})
}
})
}
</script>

View File

@ -0,0 +1,281 @@
<template>
<SelectTemplateModal />
<ItemModal />
<TaxTypeModal />
<SalesTax
v-if="salesTaxEnabled && (!isLoadingContent || route.query.customer)"
:store="estimateStore"
store-prop="newEstimate"
:is-edit="isEdit"
:customer="estimateStore.newEstimate.customer"
/>
<BasePage class="relative estimate-create-page">
<form @submit.prevent="submitForm">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$tc('estimates.estimate', 2)"
to="/admin/estimates"
/>
<BaseBreadcrumbItem
v-if="$route.name === 'estimates.edit'"
:title="$t('estimates.edit_estimate')"
to="#"
active
/>
<BaseBreadcrumbItem
v-else
:title="$t('estimates.new_estimate')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<router-link
v-if="$route.name === 'estimates.edit'"
:to="`/estimates/pdf/${estimateStore.newEstimate.unique_hash}`"
target="_blank"
>
<BaseButton class="mr-3" variant="primary-outline" type="button">
<span class="flex">
{{ $t('general.view_pdf') }}
</span>
</BaseButton>
</router-link>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isLoadingContent"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="SaveIcon"
/>
</template>
{{ $t('estimates.save_estimate') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Select Customer & Basic Fields -->
<EstimateBasicFields
:v="v$"
:is-loading="isLoadingContent"
:is-edit="isEdit"
/>
<BaseScrollPane>
<!-- Estimate Items -->
<Items
:currency="estimateStore.newEstimate.selectedCurrency"
:is-loading="isLoadingContent"
:item-validation-scope="estimateValidationScope"
:store="estimateStore"
store-prop="newEstimate"
/>
<!-- Estimate Footer Section -->
<div
class="
block
mt-10
estimate-foot
lg:flex lg:justify-between lg:items-start
"
>
<div class="relative w-full lg:w-1/2">
<!-- Estimate Custom Notes -->
<NoteFields
:store="estimateStore"
store-prop="newEstimate"
:fields="estimateNoteFieldList"
type="Estimate"
/>
<!-- Estimate Custom Fields -->
<EstimateCustomFields
type="Estimate"
:is-edit="isEdit"
:is-loading="isLoadingContent"
:store="estimateStore"
store-prop="newEstimate"
:custom-field-scope="estimateValidationScope"
class="mb-6"
/>
<!-- Estimate Template Button-->
<SelectTemplate
:store="estimateStore"
component-name="EstimateTemplate"
store-prop="newEstimate"
/>
</div>
<Total
:currency="estimateStore.newEstimate.selectedCurrency"
:is-loading="isLoadingContent"
:store="estimateStore"
store-prop="newEstimate"
tax-popup-type="estimate"
/>
</div>
</BaseScrollPane>
</form>
</BasePage>
</template>
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
maxLength,
helpers,
requiredIf,
decimal,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModuleStore } from '@/scripts/admin/stores/module'
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import Items from '@/scripts/admin/components/estimate-invoice-common/CreateItems.vue'
import Total from '@/scripts/admin/components/estimate-invoice-common/CreateTotal.vue'
import SelectTemplate from '@/scripts/admin/components/estimate-invoice-common/SelectTemplateButton.vue'
import EstimateCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import NoteFields from '@/scripts/admin/components/estimate-invoice-common/CreateNotesField.vue'
import EstimateBasicFields from './EstimateCreateBasicFields.vue'
import SelectTemplateModal from '@/scripts/admin/components/modal-components/SelectTemplateModal.vue'
import TaxTypeModal from '@/scripts/admin/components/modal-components/TaxTypeModal.vue'
import ItemModal from '@/scripts/admin/components/modal-components/ItemModal.vue'
import SalesTax from '@/scripts/admin/components/estimate-invoice-common/SalesTax.vue'
const estimateStore = useEstimateStore()
const moduleStore = useModuleStore()
const companyStore = useCompanyStore()
const customFieldStore = useCustomFieldStore()
const { t } = useI18n()
const estimateValidationScope = 'newEstimate'
let isSaving = ref(false)
const estimateNoteFieldList = ref([
'customer',
'company',
'customerCustom',
'estimate',
'estimateCustom',
])
let route = useRoute()
let router = useRouter()
let isLoadingContent = computed(() => estimateStore.isFetchingInitialSettings)
let pageTitle = computed(() =>
isEdit.value ? t('estimates.edit_estimate') : t('estimates.new_estimate')
)
let isEdit = computed(() => route.name === 'estimates.edit')
const salesTaxEnabled = computed(() => {
return (
companyStore.selectedCompanySettings.sales_tax_us_enabled === 'YES' &&
moduleStore.salesTaxUSEnabled
)
})
const rules = {
estimate_date: {
required: helpers.withMessage(t('validation.required'), required),
},
estimate_number: {
required: helpers.withMessage(t('validation.required'), required),
},
reference_number: {
maxLength: helpers.withMessage(
t('validation.price_maxlength'),
maxLength(255)
),
},
customer_id: {
required: helpers.withMessage(t('validation.required'), required),
},
exchange_rate: {
required: requiredIf(function () {
helpers.withMessage(t('validation.required'), required)
return estimateStore.showExchangeRate
}),
decimal: helpers.withMessage(t('validation.valid_exchange_rate'), decimal),
},
}
const v$ = useVuelidate(
rules,
computed(() => estimateStore.newEstimate),
{ $scope: estimateValidationScope }
)
watch(
() => estimateStore.newEstimate.customer,
(newVal) => {
if (newVal && newVal.currency) {
estimateStore.newEstimate.selectedCurrency = newVal.currency
} else {
estimateStore.newEstimate.selectedCurrency =
companyStore.selectedCompanyCurrency
}
}
)
estimateStore.resetCurrentEstimate()
customFieldStore.resetCustomFields()
v$.value.$reset
estimateStore.fetchEstimateInitialSettings(isEdit.value)
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return false
}
isSaving.value = true
let data = {
...estimateStore.newEstimate,
sub_total: estimateStore.getSubTotal,
total: estimateStore.getTotal,
tax: estimateStore.getTotalTax,
}
const action = isEdit.value
? estimateStore.updateEstimate
: estimateStore.addEstimate
try {
let res = await action(data)
if (res.data.data) {
router.push(`/admin/estimates/${res.data.data.id}/view`)
}
} catch (err) {
console.error(err)
}
isSaving.value = false
}
</script>

View File

@ -0,0 +1,102 @@
<template>
<div class="md:grid-cols-12 grid-cols-1 md:gap-x-6 mt-6 mb-8 grid gap-y-5">
<BaseCustomerSelectPopup
v-model="estimateStore.newEstimate.customer"
:valid="v.customer_id"
:content-loading="isLoading"
type="estimate"
class="col-span-5 pr-0"
/>
<BaseInputGrid class="col-span-7">
<BaseInputGroup
:label="$t('reports.estimates.estimate_date')"
:content-loading="isLoading"
required
:error="v.estimate_date.$error && v.estimate_date.$errors[0].$message"
>
<BaseDatePicker
v-model="estimateStore.newEstimate.estimate_date"
:content-loading="isLoading"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('estimates.expiry_date')"
:content-loading="isLoading"
>
<BaseDatePicker
v-model="estimateStore.newEstimate.expiry_date"
:content-loading="isLoading"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('estimates.estimate_number')"
:content-loading="isLoading"
required
:error="
v.estimate_number.$error && v.estimate_number.$errors[0].$message
"
>
<BaseInput
v-model="estimateStore.newEstimate.estimate_number"
:content-loading="isLoading"
>
</BaseInput>
</BaseInputGroup>
<!-- <BaseInputGroup
:label="$t('estimates.ref_number')"
:content-loading="isLoading"
:error="
v.reference_number.$error && v.reference_number.$errors[0].$message
"
>
<BaseInput
v-model="estimateStore.newEstimate.reference_number"
:content-loading="isLoading"
@input="v.reference_number.$touch()"
>
<template #left="slotProps">
<BaseIcon name="HashtagIcon" :class="slotProps.class" />
</template>
</BaseInput>
</BaseInputGroup> -->
<ExchangeRateConverter
:store="estimateStore"
store-prop="newEstimate"
:v="v"
:is-loading="isLoading"
:is-edit="isEdit"
:customer-currency="estimateStore.newEstimate.currency_id"
/>
</BaseInputGrid>
</div>
</template>
<script setup>
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import ExchangeRateConverter from '@/scripts/admin/components/estimate-invoice-common/ExchangeRateConverter.vue'
const props = defineProps({
v: {
type: Object,
default: null,
},
isLoading: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
})
const estimateStore = useEstimateStore()
</script>

View File

@ -0,0 +1,475 @@
<template>
<CategoryModal />
<BasePage class="relative">
<form action="" @submit.prevent="submitForm">
<!-- Page Header -->
<BasePageHeader :title="pageTitle" class="mb-5">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$tc('expenses.expense', 2)"
to="/admin/expenses"
/>
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-if="isEdit && expenseStore.currentExpense.attachment_receipt"
:href="receiptDownloadUrl"
tag="a"
variant="primary-outline"
type="button"
class="mr-2"
>
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
</template>
{{ $t('expenses.download_receipt') }}
</BaseButton>
<div class="hidden md:block">
<BaseButton
:loading="isSaving"
:content-loading="isFetchingInitialData"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('expenses.update_expense')
: $t('expenses.save_expense')
}}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseCard>
<BaseInputGrid>
<BaseInputGroup
:label="$t('expenses.category')"
:error="
v$.currentExpense.expense_category_id.$error &&
v$.currentExpense.expense_category_id.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="expenseStore.currentExpense.expense_category_id"
:content-loading="isFetchingInitialData"
value-prop="id"
label="name"
track-by="id"
:options="searchCategory"
:filter-results="false"
resolve-on-load
:delay="500"
searchable
:invalid="v$.currentExpense.expense_category_id.$error"
:placeholder="$t('expenses.categories.select_a_category')"
@input="v$.currentExpense.expense_category_id.$touch()"
>
<template #action>
<BaseSelectAction @click="openCategoryModal">
<BaseIcon
name="PlusIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('settings.expense_category.add_new_category') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:label="$t('expenses.expense_date')"
:error="
v$.currentExpense.expense_date.$error &&
v$.currentExpense.expense_date.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseDatePicker
v-model="expenseStore.currentExpense.expense_date"
:content-loading="isFetchingInitialData"
:calendar-button="true"
:invalid="v$.currentExpense.expense_date.$error"
@input="v$.currentExpense.expense_date.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('expenses.amount')"
:error="
v$.currentExpense.amount.$error &&
v$.currentExpense.amount.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMoney
:key="expenseStore.currentExpense.selectedCurrency"
v-model="amountData"
class="focus:border focus:border-solid focus:border-primary-500"
:invalid="v$.currentExpense.amount.$error"
:currency="expenseStore.currentExpense.selectedCurrency"
@input="v$.currentExpense.amount.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('expenses.currency')"
:content-loading="isFetchingInitialData"
:error="
v$.currentExpense.currency_id.$error &&
v$.currentExpense.currency_id.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="expenseStore.currentExpense.currency_id"
value-prop="id"
label="name"
track-by="name"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
searchable
:can-deselect="false"
:placeholder="$t('customers.select_currency')"
:invalid="v$.currentExpense.currency_id.$error"
class="w-full"
@update:modelValue="onCurrencyChange"
>
</BaseMultiselect>
</BaseInputGroup>
<!-- Exchange rate converter -->
<ExchangeRateConverter
:store="expenseStore"
store-prop="currentExpense"
:v="v$.currentExpense"
:is-loading="isFetchingInitialData"
:is-edit="isEdit"
:customer-currency="expenseStore.currentExpense.currency_id"
/>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('expenses.customer')"
>
<BaseMultiselect
v-model="expenseStore.currentExpense.customer_id"
:content-loading="isFetchingInitialData"
value-prop="id"
label="name"
track-by="id"
:options="searchCustomer"
:filter-results="false"
resolve-on-load
:delay="500"
searchable
:placeholder="$t('customers.select_a_customer')"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('payments.payment_mode')"
>
<BaseMultiselect
v-model="expenseStore.currentExpense.payment_method_id"
:content-loading="isFetchingInitialData"
label="name"
value-prop="id"
track-by="name"
:options="expenseStore.paymentModes"
:placeholder="$t('payments.select_payment_mode')"
searchable
>
<!-- <template #action>
<BaseSelectAction @click="addPaymentMode">
<BaseIcon
name="PlusIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('settings.payment_modes.add_payment_mode') }}
</BaseSelectAction>
</template> -->
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('expenses.note')"
:error="
v$.currentExpense.notes.$error &&
v$.currentExpense.notes.$errors[0].$message
"
>
<BaseTextarea
v-model="expenseStore.currentExpense.notes"
:content-loading="isFetchingInitialData"
:row="4"
rows="4"
@input="v$.currentExpense.notes.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('expenses.receipt')">
<BaseFileUploader
v-model="expenseStore.currentExpense.receiptFiles"
accept="image/*,.doc,.docx,.pdf,.csv,.xlsx,.xls"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
<!-- Expense Custom Fields -->
<ExpenseCustomFields
:is-edit="isEdit"
class="col-span-2"
:is-loading="isFetchingInitialData"
type="Expense"
:store="expenseStore"
store-prop="currentExpense"
:custom-field-scope="expenseValidationScope"
/>
<div class="block md:hidden">
<BaseButton
:loading="isSaving"
:tabindex="6"
variant="primary"
type="submit"
class="flex justify-center w-full"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('expenses.update_expense')
: $t('expenses.save_expense')
}}
</BaseButton>
</div>
</BaseInputGrid>
</BaseCard>
</form>
</BasePage>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minValue,
maxLength,
helpers,
requiredIf,
decimal,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useExpenseStore } from '@/scripts/admin/stores/expense'
import { useCategoryStore } from '@/scripts/admin/stores/category'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import { useModalStore } from '@/scripts/stores/modal'
import ExpenseCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import CategoryModal from '@/scripts/admin/components/modal-components/CategoryModal.vue'
import ExchangeRateConverter from '@/scripts/admin/components/estimate-invoice-common/ExchangeRateConverter.vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const customerStore = useCustomerStore()
const companyStore = useCompanyStore()
const expenseStore = useExpenseStore()
const categoryStore = useCategoryStore()
const customFieldStore = useCustomFieldStore()
const modalStore = useModalStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const globalStore = useGlobalStore()
let isSaving = ref(false)
let isFetchingInitialData = ref(false)
const expenseValidationScope = 'newExpense'
const rules = computed(() => {
return {
currentExpense: {
expense_category_id: {
required: helpers.withMessage(t('validation.required'), required),
},
expense_date: {
required: helpers.withMessage(t('validation.required'), required),
},
amount: {
required: helpers.withMessage(t('validation.required'), required),
minValue: helpers.withMessage(
t('validation.price_minvalue'),
minValue(0.1)
),
maxLength: helpers.withMessage(
t('validation.price_maxlength'),
maxLength(20)
),
},
notes: {
maxLength: helpers.withMessage(
t('validation.description_maxlength'),
maxLength(65000)
),
},
currency_id: {
required: helpers.withMessage(t('validation.required'), required),
},
exchange_rate: {
required: requiredIf(function () {
helpers.withMessage(t('validation.required'), required)
return expenseStore.showExchangeRate
}),
decimal: helpers.withMessage(
t('validation.valid_exchange_rate'),
decimal
),
},
},
}
})
const v$ = useVuelidate(rules, expenseStore, {
$scope: expenseValidationScope,
})
const amountData = computed({
get: () => expenseStore.currentExpense.amount / 100,
set: (value) => {
expenseStore.currentExpense.amount = Math.round(value * 100)
},
})
const isEdit = computed(() => route.name === 'expenses.edit')
const pageTitle = computed(() =>
isEdit.value ? t('expenses.edit_expense') : t('expenses.new_expense')
)
const receiptDownloadUrl = computed(() =>
isEdit.value ? `/expenses/${route.params.id}/download-receipt` : ''
)
expenseStore.resetCurrentExpenseData()
customFieldStore.resetCustomFields()
loadData()
function onFileInputChange(fileName, file) {
expenseStore.currentExpense.attachment_receipt = file
}
function onFileInputRemove() {
expenseStore.currentExpense.attachment_receipt = null
}
function openCategoryModal() {
modalStore.openModal({
title: t('settings.expense_category.add_category'),
componentName: 'CategoryModal',
size: 'sm',
})
}
function onCurrencyChange(v) {
expenseStore.currentExpense.selectedCurrency = globalStore.currencies.find(
(c) => c.id === v
)
}
async function searchCategory(search) {
let res = await categoryStore.fetchCategories({ search })
return res.data.data
}
async function searchCustomer(search) {
let res = await customerStore.fetchCustomers({ search })
return res.data.data
}
async function loadData() {
if (!isEdit.value) {
expenseStore.currentExpense.currency_id =
companyStore.selectedCompanyCurrency.id
expenseStore.currentExpense.selectedCurrency =
companyStore.selectedCompanyCurrency
}
isFetchingInitialData.value = true
await expenseStore.fetchPaymentModes({ limit: 'all' })
if (isEdit.value) {
await expenseStore.fetchExpense(route.params.id)
expenseStore.currentExpense.currency_id =
expenseStore.currentExpense.selectedCurrency.id
} else if (route.query.customer) {
expenseStore.currentExpense.customer_id = route.query.customer
}
isFetchingInitialData.value = false
}
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
let formData = expenseStore.currentExpense
try {
if (isEdit.value) {
await expenseStore.updateExpense({
id: route.params.id,
data: formData,
})
} else {
await expenseStore.addExpense(formData)
}
isSaving.value = false
router.push('/admin/expenses')
} catch (err) {
console.error(err)
isSaving.value = false
return
}
}
</script>

View File

@ -0,0 +1,405 @@
<template>
<BasePage>
<!-- Page Header -->
<BasePageHeader :title="$t('expenses.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$tc('expenses.expense', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="expenseStore.totalExpenses"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FilterIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_EXPENSE)"
class="ml-4"
variant="primary"
@click="$router.push('expenses/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('expenses.add_expense') }}
</BaseButton>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-5" @clear="clearFilter">
<BaseInputGroup :label="$t('expenses.customer')">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('expenses.category')">
<BaseMultiselect
v-model="filters.expense_category_id"
value-prop="id"
label="name"
track-by="name"
:filter-results="false"
resolve-on-load
:delay="500"
:options="searchCategory"
searchable
:placeholder="$t('expenses.categories.select_a_category')"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('expenses.from_date')">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('expenses.to_date')">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<!-- Empty Table Placeholder -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('expenses.no_expenses')"
:description="$t('expenses.list_of_expenses')"
>
<UFOIcon class="mt-5 mb-4" />
<template
v-if="userStore.hasAbilities(abilities.CREATE_EXPENSE)"
#actions
>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/expenses/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('expenses.add_new_expense') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<div class="relative flex items-center justify-end h-5">
<BaseDropdown
v-if="
expenseStore.selectedExpenses.length &&
userStore.hasAbilities(abilities.DELETE_EXPENSE)
"
>
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.DELETE_EXPENSE)"
@click="removeMultipleExpenses"
>
<BaseIcon name="TrashIcon" class="h-5 mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableComponent"
:data="fetchData"
:columns="expenseColumns"
class="mt-3"
>
<!-- Select All Checkbox -->
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="selectAllFieldStatus"
variant="primary"
@change="expenseStore.selectAllExpenses"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-name="{ row }">
<router-link
:to="{ path: `expenses/${row.data.id}/edit` }"
class="font-medium text-primary-500"
>
{{ row.data.expense_category.name }}
</router-link>
</template>
<template #cell-amount="{ row }">
<BaseFormatMoney
:amount="row.data.amount"
:currency="row.data.currency"
/>
</template>
<template #cell-expense_date="{ row }">
{{ row.data.formatted_expense_date }}
</template>
<template #cell-user_name="{ row }">
<BaseText
:text="row.data.customer ? row.data.customer.name : '-'"
:length="30"
/>
</template>
<template #cell-notes="{ row }">
<div class="notes">
<div class="truncate note w-60">
{{ row.data.notes ? row.data.notes : '-' }}
</div>
</div>
</template>
<template v-if="hasAbilities()" #cell-actions="{ row }">
<ExpenseDropdown
:row="row.data"
:table="tableComponent"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { ref, onMounted, computed, reactive, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useExpenseStore } from '@/scripts/admin/stores/expense'
import { useCategoryStore } from '@/scripts/admin/stores/category'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { debouncedWatch } from '@vueuse/core'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import UFOIcon from '@/scripts/components/icons/empty/UFOIcon.vue'
import ExpenseDropdown from '@/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue'
const companyStore = useCompanyStore()
const expenseStore = useExpenseStore()
const dialogStore = useDialogStore()
const categoryStore = useCategoryStore()
const userStore = useUserStore()
let isFetchingInitialData = ref(true)
let showFilters = ref(null)
const filters = reactive({
expense_category_id: '',
from_date: '',
to_date: '',
customer_id: '',
})
const { t } = useI18n()
let tableComponent = ref(null)
const showEmptyScreen = computed(() => {
return !expenseStore.totalExpenses && !isFetchingInitialData.value
})
const selectField = computed({
get: () => expenseStore.selectedExpenses,
set: (value) => {
return expenseStore.selectExpense(value)
},
})
const selectAllFieldStatus = computed({
get: () => expenseStore.selectAllField,
set: (value) => {
return expenseStore.setSelectAllState(value)
},
})
const expenseColumns = computed(() => {
return [
{
key: 'status',
thClass: 'extra w-10',
tdClass: 'font-medium text-gray-900',
placeholderClass: 'w-10',
sortable: false,
},
{
key: 'expense_date',
label: 'Date',
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'name',
label: 'Category',
thClass: 'extra',
tdClass: 'cursor-pointer font-medium text-primary-500',
},
{ key: 'user_name', label: 'Customer' },
{ key: 'notes', label: 'Note' },
{ key: 'amount', label: 'Amount' },
{
key: 'actions',
sortable: false,
tdClass: 'text-right text-sm font-medium',
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (expenseStore.selectAllField) {
expenseStore.selectAllExpenses()
}
})
onMounted(() => {
categoryStore.fetchCategories({ limit: 'all' })
})
async function searchCategory(search) {
let res = await categoryStore.fetchCategories({ search })
return res.data.data
}
async function fetchData({ page, filter, sort }) {
let data = {
...filters,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await expenseStore.fetchExpenses(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
data: response.data.data,
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable() {
tableComponent.value && tableComponent.value.refresh()
}
function setFilters() {
refreshTable()
}
function clearFilter() {
filters.expense_category_id = ''
filters.from_date = ''
filters.to_date = ''
filters.customer_id = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function hasAbilities() {
return userStore.hasAbilities([
abilities.DELETE_EXPENSE,
abilities.EDIT_EXPENSE,
])
}
function removeMultipleExpenses() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('expenses.confirm_delete', 2),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
size: 'lg',
hideNoButton: false,
})
.then((res) => {
if (res) {
expenseStore.deleteMultipleExpenses().then((response) => {
if (response.data) {
refreshTable()
}
})
}
})
}
</script>

View File

@ -0,0 +1,110 @@
<template>
<div class="flex flex-col items-center justify-between w-full pt-10">
<img
id="logo-crater"
src="/img/crater-logo.png"
alt="Crater Logo"
class="h-12 mb-5 md:mb-10"
/>
<BaseWizard
:steps="7"
:current-step="currentStepNumber"
@click="onNavClick"
>
<component :is="stepComponent" @next="onStepChange" />
</BaseWizard>
</div>
</template>
<script>
import { ref } from 'vue'
import Step1RequirementsCheck from './Step1RequirementsCheck.vue'
import Step2PermissionCheck from './Step2PermissionCheck.vue'
import Step3DatabaseConfig from './Step3DatabaseConfig.vue'
import Step4VerifyDomain from './Step4VerifyDomain.vue'
import Step5EmailConfig from './Step5EmailConfig.vue'
import Step6AccountSettings from './Step6AccountSettings.vue'
import Step7CompanyInfo from './Step7CompanyInfo.vue'
import Step8CompanyPreferences from './Step8CompanyPreferences.vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { useRouter } from 'vue-router'
export default {
components: {
step_1: Step1RequirementsCheck,
step_2: Step2PermissionCheck,
step_3: Step3DatabaseConfig,
step_4: Step4VerifyDomain,
step_5: Step5EmailConfig,
step_6: Step6AccountSettings,
step_7: Step7CompanyInfo,
step_8: Step8CompanyPreferences,
},
setup() {
let stepComponent = ref('step_1')
let currentStepNumber = ref(1)
const router = useRouter()
const installationStore = useInstallationStore()
checkCurrentProgress()
async function checkCurrentProgress() {
let res = await installationStore.fetchInstallationStep()
if (res.data.profile_complete === 'COMPLETED') {
router.push('/admin/dashboard')
return
}
let dbstep = parseInt(res.data.profile_complete)
if (dbstep) {
currentStepNumber.value = dbstep + 1
stepComponent.value = `step_${dbstep + 1}`
}
}
async function saveStepProgress(data) {
let status = {
profile_complete: data,
}
try {
await installationStore.addInstallationStep(status)
return true
} catch (e) {
if (e?.response?.data?.message === 'The MAC is invalid.') {
window.location.reload()
}
return false
}
}
async function onStepChange(data) {
if (data) {
let res = await saveStepProgress(data)
if (!res) return false
}
currentStepNumber.value++
if (currentStepNumber.value <= 8) {
stepComponent.value = 'step_' + currentStepNumber.value
}
}
function onNavClick(e) {}
return {
stepComponent,
currentStepNumber,
onStepChange,
saveStepProgress,
onNavClick,
}
},
}
</script>

View File

@ -0,0 +1,114 @@
<template>
<BaseWizardStep
:title="$t('wizard.req.system_req')"
:description="$t('wizard.req.system_req_desc')"
>
<div class="w-full md:w-2/3">
<div class="mb-6">
<div
v-if="phpSupportInfo"
class="grid grid-flow-row grid-cols-3 p-3 border border-gray-200 lg:gap-24 sm:gap-4"
>
<div class="col-span-2 text-sm">
{{
$t('wizard.req.php_req_version', {
version: phpSupportInfo.minimum,
})
}}
</div>
<div class="text-right">
{{ phpSupportInfo.current }}
<span
v-if="phpSupportInfo.supported"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</div>
</div>
<div v-if="requirements">
<div
v-for="(requirement, index) in requirements"
:key="index"
class="grid grid-flow-row grid-cols-3 p-3 border border-gray-200 lg:gap-24 sm:gap-4"
>
<div class="col-span-2 text-sm">
{{ index }}
</div>
<div class="text-right">
<span
v-if="requirement"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</div>
</div>
</div>
</div>
<BaseButton v-if="hasNext" @click="next">
{{ $t('wizard.continue') }}
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="!requirements"
:loading="isSaving"
:disabled="isSaving"
@click="getRequirements"
>
{{ $t('wizard.req.check_req') }}
</BaseButton>
</div>
</BaseWizardStep>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation.js'
const emit = defineEmits(['next'])
const requirements = ref('')
const phpSupportInfo = ref('')
const isSaving = ref(false)
const isShow = ref(true)
const installationStore = useInstallationStore()
const hasNext = computed(() => {
if (requirements.value) {
let isRequired = true
for (const key in requirements.value) {
if (!requirements.value[key]) {
isRequired = false
}
return requirements.value && phpSupportInfo.value.supported && isRequired
}
}
return false
})
async function getRequirements() {
isSaving.value = true
const response = await installationStore.fetchInstallationRequirements()
if (response.data) {
requirements.value = response?.data?.requirements?.requirements?.php
phpSupportInfo.value = response?.data?.phpSupportInfo
}
}
function next() {
isSaving.value = true
emit('next')
isSaving.value = false
}
</script>

View File

@ -0,0 +1,123 @@
<template>
<BaseWizardStep
:title="$t('wizard.permissions.permissions')"
:description="$t('wizard.permissions.permission_desc')"
>
<!-- Content Placeholders -->
<BaseContentPlaceholders v-if="isFetchingInitialData">
<div
v-for="(permission, index) in 3"
:key="index"
class="
grid grid-flow-row grid-cols-3
lg:gap-24
sm:gap-4
border border-gray-200
"
>
<BaseContentPlaceholdersText :lines="1" class="col-span-4 p-3" />
</div>
<BaseContentPlaceholdersBox
:rounded="true"
class="mt-10"
style="width: 96px; height: 42px"
/>
</BaseContentPlaceholders>
<!-- End of Content Placeholder -->
<div v-else class="relative">
<div
v-for="(permission, index) in permissions"
:key="index"
class="border border-gray-200"
>
<div class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4">
<div class="col-span-2 p-3">
{{ permission.folder }}
</div>
<div class="p-3 text-right">
<span
v-if="permission.isSet"
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-green-500"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-red-500"
/>
<span>{{ permission.permission }}</span>
</div>
</div>
</div>
<BaseButton
v-show="!isFetchingInitialData"
class="mt-10"
:loading="isSaving"
:disabled="isSaving"
@click="next"
>
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.continue') }}
</BaseButton>
</div>
</BaseWizardStep>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useI18n } from 'vue-i18n'
const emit = defineEmits(['next'])
let isFetchingInitialData = ref(false)
let isSaving = ref(false)
let permissions = ref([])
const { tm, t } = useI18n()
const installationStore = useInstallationStore()
const dialogStore = useDialogStore()
onMounted(() => {
getPermissions()
})
async function getPermissions() {
isFetchingInitialData.value = true
const res = await installationStore.fetchInstallationPermissions()
permissions.value = res.data.permissions.permissions
if (res.data && res.data.permissions.errors) {
setTimeout(() => {
dialogStore
.openDialog({
title: tm('wizard.permissions.permission_confirm_title'),
message: t('wizard.permissions.permission_confirm_desc'),
yesLabel: 'OK',
noLabel: 'Cancel',
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res.data) {
isFetchingInitialData.value = false
}
})
}, 500)
}
isFetchingInitialData.value = false
}
function next() {
isSaving.value = true
emit('next')
isSaving.value = false
}
</script>

View File

@ -0,0 +1,131 @@
<template>
<BaseWizardStep
:title="$t('wizard.database.database')"
:description="$t('wizard.database.desc')"
step-container="w-full p-8 mb-8 bg-white border border-gray-200 border-solid rounded md:w-full"
>
<component
:is="databaseData.database_connection"
:config-data="databaseData"
:is-saving="isSaving"
@on-change-driver="getDatabaseConfig"
@submit-data="next"
/>
</BaseWizardStep>
</template>
<script>
import { ref, computed } from 'vue'
import Mysql from './database/MysqlDatabase.vue'
import Pgsql from './database/PgsqlDatabase.vue'
import Sqlite from './database/SqliteDatabase.vue'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { useI18n } from 'vue-i18n'
export default {
components: {
Mysql,
Pgsql,
Sqlite,
},
emits: ['next'],
setup(props, { emit }) {
const database_connection = ref('mysql')
const isSaving = ref(false)
const { t } = useI18n()
const notificationStore = useNotificationStore()
const installationStore = useInstallationStore()
const databaseData = computed(() => {
return installationStore.currentDataBaseData
})
async function getDatabaseConfig(connection) {
let params = {
connection,
}
const res = await installationStore.fetchInstallationDatabase(params)
if (res.data.success) {
databaseData.value.database_connection =
res.data.config.database_connection
}
if (connection === 'sqlite') {
databaseData.value.database_name = res.data.config.database_name
} else {
databaseData.value.database_name = null
}
}
async function next(databaseData) {
isSaving.value = true
try {
let res = await installationStore.addInstallationDatabase(databaseData)
isSaving.value = false
if (res.data.success) {
await installationStore.addInstallationFinish()
emit('next', 3)
notificationStore.showNotification({
type: 'success',
message: t('wizard.success.' + res.data.success),
})
return
} else if (res.data.error) {
if (res.data.requirement) {
notificationStore.showNotification({
type: 'error',
message: t('wizard.errors.' + res.data.error, {
version: res.data.requirement.minimum,
name: databaseData.value.database_connection,
}),
})
return
}
notificationStore.showNotification({
type: 'error',
message: t('wizard.errors.' + res.data.error),
})
} else if (res.data.errors) {
notificationStore.showNotification({
type: 'error',
message: res.data.errors[0],
})
} else if (res.data.error_message) {
notificationStore.showNotification({
type: 'error',
message: res.data.error_message,
})
}
} catch (e) {
notificationStore.showNotification({
type: 'error',
message: t('validation.something_went_wrong'),
})
isSaving.value = false
} finally {
isSaving.value = false
}
}
return {
databaseData,
database_connection,
isSaving,
getDatabaseConfig,
next,
}
},
}
</script>

View File

@ -0,0 +1,108 @@
<template>
<BaseWizardStep
:title="$t('wizard.verify_domain.title')"
:description="$t('wizard.verify_domain.desc')"
>
<div class="w-full md:w-2/3">
<BaseInputGroup
:label="$t('wizard.verify_domain.app_domain')"
:error="v$.app_domain.$error && v$.app_domain.$errors[0].$message"
required
>
<BaseInput
v-model="formData.app_domain"
:invalid="v$.app_domain.$error"
type="text"
@input="v$.app_domain.$touch()"
/>
</BaseInputGroup>
</div>
<p class="mt-4 mb-0 text-sm text-gray-600">Notes:</p>
<ul class="w-full text-gray-600 list-disc list-inside">
<li class="text-sm leading-8">
App domain should not contain
<b class="inline-block px-1 bg-gray-100 rounded-sm">https://</b> or
<b class="inline-block px-1 bg-gray-100 rounded-sm">http</b> in front of
the domain.
</li>
<li class="text-sm leading-8">
If you're accessing the website on a different port, please mention the
port. For example:
<b class="inline-block px-1 bg-gray-100">localhost:8080</b>
</li>
</ul>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
class="mt-8"
@click="verifyDomain"
>
{{ $t('wizard.verify_domain.verify_now') }}
</BaseButton>
</BaseWizardStep>
</template>
<script setup>
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { ref, inject, computed, reactive } from 'vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useI18n } from 'vue-i18n'
const emit = defineEmits(['next'])
const formData = reactive({
app_domain: window.location.origin.replace(/(^\w+:|^)\/\//, ''),
})
const isSaving = ref(false)
const { t } = useI18n()
const utils = inject('utils')
const isUrl = (value) => utils.checkValidDomainUrl(value)
const installationStore = useInstallationStore()
const notificationStore = useNotificationStore()
const rules = {
app_domain: {
required: helpers.withMessage(t('validation.required'), required),
isUrl: helpers.withMessage(t('validation.invalid_domain_url'), isUrl),
},
}
const v$ = useVuelidate(
rules,
computed(() => formData)
)
async function verifyDomain() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
try {
await installationStore.setInstallationDomain(formData)
await installationStore.installationLogin()
let driverRes = await installationStore.checkAutheticated()
if (driverRes.data) {
emit('next', 4)
}
isSaving.value = false
} catch (e) {
notificationStore.showNotification({
type: 'error',
message: t('wizard.verify_domain.failed'),
})
isSaving.value = false
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<BaseWizardStep
:title="$t('wizard.mail.mail_config')"
:description="$t('wizard.mail.mail_config_desc')"
>
<form action="" @submit.prevent="next">
<component
:is="mailDriverStore.mail_driver"
:config-data="mailDriverStore.mailConfigData"
:is-saving="isSaving"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="(val) => changeDriver(val)"
@submit-data="next"
/>
</form>
</BaseWizardStep>
</template>
<script>
import Smtp from './mail-driver/SmtpMailDriver.vue'
import Mailgun from './mail-driver/MailgunMailDriver.vue'
import Ses from './mail-driver/SesMailDriver.vue'
import Basic from './mail-driver/BasicMailDriver.vue'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
import { ref } from 'vue'
export default {
components: {
Smtp,
Mailgun,
Ses,
sendmail: Basic,
Mail: Basic,
},
emits: ['next'],
setup(props, { emit }) {
const isSaving = ref(false)
const isFetchingInitialData = ref(false)
const mailDriverStore = useMailDriverStore()
mailDriverStore.mail_driver = 'mail'
loadData()
function changeDriver(value) {
mailDriverStore.mail_driver = value
}
async function loadData() {
isFetchingInitialData.value = true
await mailDriverStore.fetchMailDrivers()
isFetchingInitialData.value = false
}
async function next(mailConfigData) {
isSaving.value = true
let res = await mailDriverStore.updateMailConfig(mailConfigData)
isSaving.value = false
if (res.data.success) {
await emit('next', 5)
}
}
return {
mailDriverStore,
isSaving,
isFetchingInitialData,
changeDriver,
next,
}
},
}
</script>

View File

@ -0,0 +1,229 @@
<template>
<BaseWizardStep
:title="$t('wizard.account_info')"
:description="$t('wizard.account_info_desc')"
>
<form action="" @submit.prevent="next">
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$tc('settings.account_settings.profile_picture')"
>
<BaseFileUploader
:avatar="true"
:preview-image="avatarUrl"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.name')"
:error="
v$.userForm.name.$error && v$.userForm.name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="userForm.name"
:invalid="v$.userForm.name.$error"
type="text"
name="name"
@input="v$.userForm.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.email')"
:error="
v$.userForm.email.$error && v$.userForm.email.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="userForm.email"
:invalid="v$.userForm.email.$error"
type="text"
name="email"
@input="v$.userForm.email.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.password')"
:error="
v$.userForm.password.$error &&
v$.userForm.password.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="userForm.password"
:invalid="v$.userForm.password.$error"
:type="isShowPassword ? 'text' : 'password'"
name="password"
@input="v$.userForm.password.$touch()"
>
<template #right>
<EyeOffIcon
v-if="isShowPassword"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<EyeIcon
v-else
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.confirm_password')"
:error="
v$.userForm.confirm_password.$error &&
v$.userForm.confirm_password.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="userForm.confirm_password"
:invalid="v$.userForm.confirm_password.$error"
:type="isShowConfirmPassword ? 'text' : 'password'"
name="confirm_password"
@input="v$.userForm.confirm_password.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowConfirmPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup>
import {
helpers,
required,
requiredIf,
sameAs,
minLength,
email,
} from '@vuelidate/validators'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVuelidate } from '@vuelidate/core'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const emit = defineEmits(['next'])
let isSaving = ref(false)
const isShowPassword = ref(false)
const isShowConfirmPassword = ref(false)
let avatarUrl = ref('')
let avatarFileBlob = ref(null)
const userStore = useUserStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
const userForm = computed(() => {
return userStore.userForm
})
const rules = computed(() => {
return {
userForm: {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
confirm_password: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(userStore.userForm.password)
),
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(userStore.userForm.password)
),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => userStore)
)
function onFileInputChange(fileName, file) {
avatarFileBlob.value = file
}
function onFileInputRemove() {
avatarFileBlob.value = null
}
async function next() {
v$.value.userForm.$touch()
if (v$.value.userForm.$invalid) {
return true
}
isSaving.value = true
let res = await userStore.updateCurrentUser(userForm.value)
isSaving.value = false
if (res.data.data) {
if (avatarFileBlob.value) {
let avatarData = new FormData()
avatarData.append('admin_avatar', avatarFileBlob.value)
await userStore.uploadAvatar(avatarData)
}
const company = res.data.data.companies[0]
await companyStore.setSelectedCompany(company)
emit('next', 6)
}
}
</script>

View File

@ -0,0 +1,247 @@
<template>
<BaseWizardStep
:title="$t('wizard.company_info')"
:description="$t('wizard.company_info_desc')"
step-container="bg-white border border-gray-200 border-solid mb-8 md:w-full p-8 rounded w-full"
>
<form action="" @submit.prevent="next">
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$tc('settings.company_info.company_logo')">
<BaseFileUploader
base64
:preview-image="previewLogo"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.company_name')"
:error="
v$.companyForm.name.$error &&
v$.companyForm.name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="companyForm.name"
:invalid="v$.companyForm.name.$error"
type="text"
name="name"
@input="v$.companyForm.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.country')"
:error="
v$.companyForm.address.country_id.$error &&
v$.companyForm.address.country_id.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="companyForm.address.country_id"
label="name"
:invalid="v$.companyForm.address.country_id.$error"
:options="globalStore.countries"
value-prop="id"
:can-deselect="false"
:can-clear="false"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.select_country')"
searchable
track-by="name"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.state')">
<BaseInput
v-model="companyForm.address.state"
name="state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.city')">
<BaseInput
v-model="companyForm.address.city"
name="city"
type="text"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<div>
<BaseInputGroup
:label="$t('wizard.address')"
:error="
v$.companyForm.address.address_street_1.$error &&
v$.companyForm.address.address_street_1.$errors[0].$message
"
>
<BaseTextarea
v-model.trim="companyForm.address.address_street_1"
:invalid="v$.companyForm.address.address_street_1.$error"
:placeholder="$t('general.street_1')"
name="billing_street1"
rows="2"
@input="v$.companyForm.address.address_street_1.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.companyForm.address.address_street_2.$error &&
v$.companyForm.address.address_street_2.$errors[0].$message
"
class="mt-1 lg:mt-2 md:mt-2"
>
<BaseTextarea
v-model="companyForm.address.address_street_2"
:invalid="v$.companyForm.address.address_street_2.$error"
:placeholder="$t('general.street_2')"
name="billing_street2"
rows="2"
@input="v$.companyForm.address.address_street_2.$touch()"
/>
</BaseInputGroup>
</div>
<div>
<BaseInputGroup :label="$t('wizard.zip_code')">
<BaseInput
v-model.trim="companyForm.address.zip"
type="text"
name="zip"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.phone')" class="mt-4">
<BaseInput
v-model.trim="companyForm.address.phone"
type="text"
name="phone"
/>
</BaseInputGroup>
</div>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, maxLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const emit = defineEmits(['next'])
let isFetchingInitialData = ref(false)
let isSaving = ref(false)
const { t } = useI18n()
let previewLogo = ref(null)
let logoFileBlob = ref(null)
let logoFileName = ref(null)
const companyForm = reactive({
name: null,
address: {
address_street_1: '',
address_street_2: '',
website: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
onMounted(async () => {
isFetchingInitialData.value = true
await globalStore.fetchCountries()
isFetchingInitialData.value = false
})
const rules = {
companyForm: {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
address: {
country_id: {
required: helpers.withMessage(t('validation.required'), required),
},
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
address_street_2: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
},
},
}
const v$ = useVuelidate(rules, { companyForm })
function onFileInputChange(fileName, file, fileCount, fileList) {
logoFileName.value = fileList.name
logoFileBlob.value = file
}
function onFileInputRemove() {
logoFileBlob.value = null
}
async function next() {
v$.value.companyForm.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
let res = companyStore.updateCompany(companyForm)
if (res) {
if (logoFileBlob.value) {
let logoData = new FormData()
logoData.append(
'company_logo',
JSON.stringify({
name: logoFileName.value,
data: logoFileBlob.value,
})
)
await companyStore.updateCompanyLogo(logoData)
}
isSaving.value = false
emit('next', 7)
}
}
</script>

View File

@ -0,0 +1,309 @@
<template>
<BaseWizardStep
:title="$t('wizard.preferences')"
:description="$t('wizard.preferences_desc')"
step-container="bg-white border border-gray-200 border-solid mb-8 md:w-full p-8 rounded w-full"
>
<form action="" @submit.prevent="next">
<div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.currency')"
:error="
v$.currentPreferences.currency.$error &&
v$.currentPreferences.currency.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.currency"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
label="name"
value-prop="id"
:searchable="true"
track-by="name"
:placeholder="$tc('settings.currencies.select_currency')"
:invalid="v$.currentPreferences.currency.$error"
class="w-full"
>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.default_language')"
:error="
v$.currentPreferences.language.$error &&
v$.currentPreferences.language.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.language"
:content-loading="isFetchingInitialData"
:options="globalStore.languages"
label="name"
value-prop="code"
:placeholder="$tc('settings.preferences.select_language')"
class="w-full"
track-by="code"
:searchable="true"
:invalid="v$.currentPreferences.language.$error"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.date_format')"
:error="
v$.currentPreferences.carbon_date_format.$error &&
v$.currentPreferences.carbon_date_format.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.carbon_date_format"
:content-loading="isFetchingInitialData"
:options="globalStore.dateFormats"
label="display_date"
value-prop="carbon_format_value"
:placeholder="$tc('settings.preferences.select_date_format')"
track-by="display_date"
searchable
:invalid="v$.currentPreferences.carbon_date_format.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.time_zone')"
:error="
v$.currentPreferences.time_zone.$error &&
v$.currentPreferences.time_zone.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.time_zone"
:content-loading="isFetchingInitialData"
:options="globalStore.timeZones"
label="key"
value-prop="value"
:placeholder="$tc('settings.preferences.select_time_zone')"
track-by="value"
:searchable="true"
:invalid="v$.currentPreferences.time_zone.$error"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.fiscal_year')"
:error="
v$.currentPreferences.fiscal_year.$error &&
v$.currentPreferences.fiscal_year.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.fiscal_year"
:content-loading="isFetchingInitialData"
:options="globalStore.fiscalYears"
label="key"
value-prop="value"
:placeholder="$tc('settings.preferences.select_financial_year')"
:invalid="v$.currentPreferences.fiscal_year.$error"
track-by="key"
:searchable="true"
class="w-full"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</div>
</form>
</BaseWizardStep>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { ref, computed, onMounted, reactive } from 'vue'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import Ls from '@/scripts/services/ls.js'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useRouter } from 'vue-router'
const emit = defineEmits(['next'])
const isSaving = ref(false)
let isFetchingInitialData = ref(false)
let currentPreferences = reactive({
currency: 1,
language: 'en',
carbon_date_format: 'd M Y',
time_zone: 'UTC',
fiscal_year: '1-12',
})
const { tm, t } = useI18n()
const router = useRouter()
isFetchingInitialData.value = true
const options = reactive([
{
title: tm('settings.customization.invoices.allow'),
value: 'allow',
},
{
title: tm(
'settings.customization.invoices.disable_on_invoice_partial_paid'
),
value: 'disable_on_invoice_partial_paid',
},
{
title: tm('settings.customization.invoices.disable_on_invoice_paid'),
value: 'disable_on_invoice_paid',
},
{
title: tm('settings.customization.invoices.disable_on_invoice_sent'),
value: 'disable_on_invoice_sent',
},
])
const dialogStore = useDialogStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
const notificationStore = useNotificationStore()
let fiscalYears = {
key: 'fiscal_years',
}
let data = {
key: 'languages',
}
isFetchingInitialData.value = true
Promise.all([
globalStore.fetchCurrencies(),
globalStore.fetchDateFormats(),
globalStore.fetchTimeZones(),
globalStore.fetchCountries(),
globalStore.fetchConfig(fiscalYears),
globalStore.fetchConfig(data),
]).then(([res1]) => {
isFetchingInitialData.value = false
})
const rules = computed(() => {
return {
currentPreferences: {
currency: {
required: helpers.withMessage(t('validation.required'), required),
},
language: {
required: helpers.withMessage(t('validation.required'), required),
},
carbon_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
time_zone: {
required: helpers.withMessage(t('validation.required'), required),
},
fiscal_year: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(rules, { currentPreferences })
async function next() {
v$.value.currentPreferences.$touch()
if (v$.value.$invalid) {
return true
}
dialogStore
.openDialog({
title: t('general.do_you_wish_to_continue'),
message: t('wizard.currency_set_alert'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
size: 'lg',
hideNoButton: false,
})
.then(async (res) => {
if (res) {
let data = {
settings: {
...currentPreferences,
},
}
isSaving.value = true
delete data.settings.discount_per_item
let res = await companyStore.updateCompanySettings({
data,
})
if (res.data) {
isSaving.value = false
let data = {
settings: {
language: currentPreferences.language,
},
}
let res1 = await userStore.updateUserSettings(data)
if (res1.data) {
emit('next', 'COMPLETED')
notificationStore.showNotification({
type: 'success',
message: 'Login Successful',
})
router.push('/admin/dashboard')
}
Ls.set('auth.token', res.data.token)
}
return true
}
isSaving.value = false
return true
})
}
</script>

View File

@ -0,0 +1,188 @@
<template>
<form action="" @submit.prevent="next">
<div class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:mb-6 md:mb-6">
<BaseInputGroup
:label="$t('wizard.database.app_url')"
:error="v$.app_url.$error && v$.app_url.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.app_url"
:invalid="v$.app_url.$error"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.connection')"
:error="
v$.database_connection.$error &&
v$.database_connection.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="databaseData.database_connection"
:invalid="v$.database_connection.$error"
:options="connections"
:can-deselect="false"
:can-clear="false"
@update:modelValue="onChangeConnection"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.port')"
:error="v$.database_port.$error && v$.database_port.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.database_port"
:invalid="v$.database_port.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.db_name')"
:error="v$.database_name.$error && v$.database_name.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.database_name"
:invalid="v$.database_name.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.username')"
:error="
v$.database_username.$error &&
v$.database_username.$errors[0].$message
"
required
>
<BaseInput
v-model="databaseData.database_username"
:invalid="v$.database_username.$error"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.database.password')">
<BaseInput v-model="databaseData.database_password" type="password" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.host')"
:error="
v$.database_hostname.$error &&
v$.database_hostname.$errors[0].$message
"
required
>
<BaseInput
v-model="databaseData.database_hostname"
:invalid="v$.database_hostname.$error"
/>
</BaseInputGroup>
</div>
<BaseButton
type="submit"
class="mt-4"
:loading="isSaving"
:disabled="isSaving"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</template>
<script setup>
import { computed, onMounted, reactive, inject } from 'vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { helpers, required, numeric } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const connections = reactive(['sqlite', 'mysql', 'pgsql'])
const { t } = useI18n()
const utils = inject('utils')
const installationStore = useInstallationStore()
onMounted(() => {
for (const key in databaseData.value) {
if (props.configData.hasOwnProperty(key)) {
databaseData.value[key] = props.configData[key]
}
}
})
const databaseData = computed(() => {
return installationStore.currentDataBaseData
})
const isUrl = (value) => utils.checkValidUrl(value)
const rules = {
database_connection: {
required: helpers.withMessage(t('validation.required'), required),
},
database_hostname: {
required: helpers.withMessage(t('validation.required'), required),
},
database_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: numeric,
},
database_name: {
required: helpers.withMessage(t('validation.required'), required),
},
database_username: {
required: helpers.withMessage(t('validation.required'), required),
},
app_url: {
required: helpers.withMessage(t('validation.required'), required),
isUrl: helpers.withMessage(t('validation.invalid_url'), isUrl),
},
}
const v$ = useVuelidate(rules, databaseData.value)
function next() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
emit('submit-data', databaseData.value)
}
function onChangeConnection() {
v$.value.database_connection.$touch()
emit('on-change-driver', databaseData.value.database_connection)
}
</script>

View File

@ -0,0 +1,210 @@
<template>
<form action="" @submit.prevent="next">
<div class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:mb-6 md:mb-6">
<BaseInputGroup
:label="$t('wizard.database.app_url')"
:content-loading="isFetchingInitialData"
:error="v$.app_url.$error && v$.app_url.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.app_url"
:content-loading="isFetchingInitialData"
:invalid="v$.app_url.$error"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.connection')"
:content-loading="isFetchingInitialData"
:error="
v$.database_connection.$error &&
v$.database_connection.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="databaseData.database_connection"
:content-loading="isFetchingInitialData"
:invalid="v$.database_connection.$error"
:options="connections"
:can-deselect="false"
:can-clear="false"
@update:modelValue="onChangeConnection"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.port')"
:content-loading="isFetchingInitialData"
:error="v$.database_port.$error && v$.database_port.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.database_port"
:content-loading="isFetchingInitialData"
:invalid="v$.database_port.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.db_name')"
:content-loading="isFetchingInitialData"
:error="v$.database_name.$error && v$.database_name.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.database_name"
:content-loading="isFetchingInitialData"
:invalid="v$.database_name.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.username')"
:content-loading="isFetchingInitialData"
:error="
v$.database_username.$error &&
v$.database_username.$errors[0].$message
"
required
>
<BaseInput
v-model="databaseData.database_username"
:content-loading="isFetchingInitialData"
:invalid="v$.database_username.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('wizard.database.password')"
>
<BaseInput
v-model="databaseData.database_password"
:content-loading="isFetchingInitialData"
type="password"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.host')"
:content-loading="isFetchingInitialData"
:error="
v$.database_hostname.$error &&
v$.database_hostname.$errors[0].$message
"
required
>
<BaseInput
v-model="databaseData.database_hostname"
:content-loading="isFetchingInitialData"
:invalid="v$.database_hostname.$error"
/>
</BaseInputGroup>
</div>
<BaseButton
v-show="!isFetchingInitialData"
:content-loading="isFetchingInitialData"
type="submit"
class="mt-4"
:loading="isSaving"
:disabled="isSaving"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</template>
<script setup>
import { computed, onMounted, reactive, inject } from 'vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { helpers, required, numeric } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const connections = reactive(['sqlite', 'mysql', 'pgsql'])
const { t } = useI18n()
const utils = inject('utils')
const installationStore = useInstallationStore()
const databaseData = computed(() => {
return installationStore.currentDataBaseData
})
onMounted(() => {
for (const key in databaseData.value) {
if (props.configData.hasOwnProperty(key)) {
databaseData.value[key] = props.configData[key]
}
}
})
const isUrl = (value) => utils.checkValidUrl(value)
const rules = {
database_connection: {
required: helpers.withMessage(t('validation.required'), required),
},
database_hostname: {
required: helpers.withMessage(t('validation.required'), required),
},
database_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: numeric,
},
database_name: {
required: helpers.withMessage(t('validation.required'), required),
},
database_username: {
required: helpers.withMessage(t('validation.required'), required),
},
app_url: {
required: helpers.withMessage(t('validation.required'), required),
isUrl: helpers.withMessage(t('validation.invalid_url'), isUrl),
},
}
const v$ = useVuelidate(rules, databaseData.value)
function next() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
emit('submit-data', databaseData.value)
}
function onChangeConnection() {
v$.value.database_connection.$touch()
emit('on-change-driver', databaseData.value.database_connection)
}
</script>

View File

@ -0,0 +1,146 @@
<template>
<form action="" @submit.prevent="next">
<div class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:mb-6 md:mb-6">
<BaseInputGroup
:label="$t('wizard.database.app_url')"
:content-loading="isFetchingInitialData"
:error="v$.app_url.$error && v$.app_url.$errors[0].$message"
required
>
<BaseInput
v-model="databaseData.app_url"
:content-loading="isFetchingInitialData"
:invalid="v$.app_url.$error"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.connection')"
:content-loading="isFetchingInitialData"
:error="
v$.database_connection.$error &&
v$.database_connection.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="databaseData.database_connection"
:content-loading="isFetchingInitialData"
:invalid="v$.database_connection.$error"
:options="connections"
:can-deselect="false"
:can-clear="false"
@update:modelValue="onChangeConnection"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.database.db_path')"
:error="v$.database_name.$error && v$.database_name.$errors[0].$message"
:content-loading="isFetchingInitialData"
required
>
<BaseInput
v-model="databaseData.database_name"
:content-loading="isFetchingInitialData"
:invalid="v$.database_name.$error"
/>
</BaseInputGroup>
</div>
<BaseButton
v-show="!isFetchingInitialData"
:content-loading="isFetchingInitialData"
type="submit"
class="mt-4"
:loading="isSaving"
:disabled="isSaving"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</template>
<script setup>
import { computed, onMounted, reactive, inject } from 'vue'
import { useInstallationStore } from '@/scripts/admin/stores/installation'
import { helpers, required } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
const props = defineProps({
configData: {
type: Object,
require: true,
default: Object,
},
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const connections = reactive(['sqlite', 'mysql', 'pgsql'])
const { t } = useI18n()
const utils = inject('utils')
const installationStore = useInstallationStore()
const databaseData = computed(() => {
return installationStore.currentDataBaseData
})
onMounted(() => {
for (const key in databaseData.value) {
if (props.configData.hasOwnProperty(key)) {
databaseData.value[key] = props.configData[key]
}
}
})
const isUrl = (value) => utils.checkValidUrl(value)
const rules = {
database_connection: {
required: helpers.withMessage(t('validation.required'), required),
},
database_name: {
required: helpers.withMessage(t('validation.required'), required),
},
app_url: {
required: helpers.withMessage(t('validation.required'), required),
isUrl: helpers.withMessage(t('validation.invalid_url'), isUrl),
},
}
const v$ = useVuelidate(rules, databaseData.value)
function next() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
emit('submit-data', databaseData.value)
}
function onChangeConnection() {
v$.value.database_connection.$touch()
emit('on-change-driver', databaseData.value.database_connection)
}
</script>

View File

@ -0,0 +1,147 @@
<template>
<form @submit.prevent="saveEmailConfig">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.basicMailConfig.mail_driver.$error &&
v$.basicMailConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="basicMailConfig.mail_driver"
:invalid="v$.basicMailConfig.mail_driver.$error"
:options="mailDriverStore.mail_drivers"
:can-deselect="false"
:content-loading="isFetchingInitialData"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.basicMailConfig.from_name.$error &&
v$.basicMailConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="basicMailConfig.from_name"
:invalid="v$.basicMailConfig.from_name.$error"
:content-loading="isFetchingInitialData"
type="text"
name="name"
@input="v$.basicMailConfig.from_name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.basicMailConfig.from_mail.$error &&
v$.basicMailConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="basicMailConfig.from_mail"
:invalid="v$.basicMailConfig.from_mail.$error"
:content-loading="isFetchingInitialData"
type="text"
@input="v$.basicMailConfig.from_mail.$touch()"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
import { computed } from 'vue'
const props = defineProps({
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const { t } = useI18n()
const mailDriverStore = useMailDriverStore()
const basicMailConfig = computed(() => {
return mailDriverStore.basicMailConfig
})
const mailDrivers = computed(() => {
return mailDriverStore.mail_drivers
})
basicMailConfig.value.mail_driver = 'mail'
const rules = computed(() => {
return {
basicMailConfig: {
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => mailDriverStore)
)
function saveEmailConfig() {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', mailDriverStore.basicMailConfig)
}
return false
}
function onChangeDriver() {
v$.value.basicMailConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore?.basicMailConfig?.mail_driver)
}
</script>

View File

@ -0,0 +1,237 @@
<template>
<form @submit.prevent="saveEmailConfig">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:mb-6 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_driver.$error &&
v$.mailgunConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailgunConfig.mail_driver"
:options="mailDriverStore.mail_drivers"
:can-deselect="false"
:invalid="v$.mailgunConfig.mail_driver.$error"
:content-loading="isFetchingInitialData"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.mailgun_domain')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_mailgun_domain.$error &&
v$.mailgunConfig.mail_mailgun_domain.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_domain"
:invalid="v$.mailgunConfig.mail_mailgun_domain.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_domain"
@input="v$.mailgunConfig.mail_mailgun_domain.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:mb-6 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.mailgun_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_mailgun_secret.$error &&
v$.mailgunConfig.mail_mailgun_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_secret"
:invalid="v$.mailgunConfig.mail_mailgun_secret.$error"
:type="getInputType"
:content-loading="isFetchingInitialData"
name="mailgun_secret"
autocomplete="off"
data-lpignore="true"
@input="v$.mailgunConfig.mail_mailgun_secret.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.mailgun_endpoint')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.mail_mailgun_endpoint.$error &&
v$.mailgunConfig.mail_mailgun_endpoint.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_endpoint"
:invalid="v$.mailgunConfig.mail_mailgun_endpoint.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_endpoint"
@input="v$.mailgunConfig.mail_mailgun_endpoint.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.from_mail.$error &&
v$.mailgunConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.from_mail"
name="from_mail"
type="text"
:invalid="v$.mailgunConfig.from_mail.$error"
:content-loading="isFetchingInitialData"
@input="v$.mailgunConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.mailgunConfig.from_name.$error &&
v$.mailgunConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.from_name"
:invalid="v$.mailgunConfig.from_name.$error"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
@input="v$.mailgunConfig.from_name.$touch()"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="loading"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { required, email, helpers } from '@vuelidate/validators'
import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
import { computed, onMounted, ref } from 'vue'
const props = defineProps({
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
let isShowPassword = ref(false)
const mailDriverStore = useMailDriverStore()
const { t } = useI18n()
const mailgunConfig = computed(() => {
return mailDriverStore.mailgunConfig
})
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
mailgunConfig.value.mail_driver = 'mailgun'
const rules = computed(() => {
return {
mailgunConfig: {
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_domain: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_endpoint: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email,
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => mailDriverStore)
)
function saveEmailConfig() {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', mailDriverStore.mailgunConfig)
}
return false
}
function onChangeDriver() {
v$.value.mailgunConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.mailgunConfig.mail_driver)
}
</script>

View File

@ -0,0 +1,278 @@
<template>
<form @submit.prevent="saveEmailConfig">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_driver.$error &&
v$.sesConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="sesConfig.mail_driver"
:options="mailDriverStore.mail_drivers"
:can-deselect="false"
:content-loading="isFetchingInitialData"
:invalid="v$.sesConfig.mail_driver.$error"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_host.$error &&
v$.sesConfig.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_host"
:invalid="v$.sesConfig.mail_host.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
@input="v$.sesConfig.mail_host.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_port.$error &&
v$.sesConfig.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_port"
:invalid="v$.sesConfig.mail_port.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
@input="v$.sesConfig.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.encryption')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_encryption.$error &&
v$.sesConfig.mail_encryption.$errors[0].$message
"
required
>
<BaseMultiselect
v-model.trim="sesConfig.mail_encryption"
:invalid="v$.sesConfig.mail_encryption.$error"
:options="encryptions"
:content-loading="isFetchingInitialData"
@input="v$.sesConfig.mail_encryption.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.from_mail.$error &&
v$.sesConfig.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.from_mail"
:invalid="v$.sesConfig.from_mail.$error"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
@input="v$.sesConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.from_name.$error &&
v$.sesConfig.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.from_name"
:invalid="v$.sesConfig.from_name.$error"
:content-loading="isFetchingInitialData"
type="text"
name="name"
@input="v$.sesConfig.from_name.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.mail.ses_key')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_ses_key.$error &&
v$.sesConfig.mail_ses_key.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_key"
:invalid="v$.sesConfig.mail_ses_key.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mail_ses_key"
@input="v$.sesConfig.mail_ses_key.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.ses_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.sesConfig.mail_ses_secret.$error &&
v$.sesConfig.mail_ses_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_secret"
:invalid="v$.sesConfig.mail_ses_secret.$error"
:type="getInputType"
:content-loading="isFetchingInitialData"
name="mail_ses_secret"
autocomplete="off"
data-lpignore="true"
@input="v$.sesConfig.mail_ses_secret.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
const props = defineProps({
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
const { t } = useI18n()
const encryptions = reactive(['tls', 'ssl', 'starttls'])
let isShowPassword = ref(false)
const mailDriverStore = useMailDriverStore()
const sesConfig = computed(() => {
return mailDriverStore.sesConfig
})
sesConfig.value.mail_driver = 'ses'
const rules = computed(() => {
return {
sesConfig: {
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric,
},
mail_ses_key: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_encryption: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => mailDriverStore)
)
async function saveEmailConfig() {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', mailDriverStore.sesConfig)
}
return false
}
function onChangeDriver() {
v$.value.sesConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.sesConfig.mail_driver)
}
</script>

View File

@ -0,0 +1,265 @@
<template>
<form @submit.prevent="saveEmailConfig">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.mail_driver.$error &&
v$.smtpConfig.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="smtpConfig.mail_driver"
:options="mailDriverStore.mail_drivers"
:can-deselect="false"
:content-loading="isFetchingInitialData"
:invalid="v$.smtpConfig.mail_driver.$error"
@update:modelValue="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.smtpConfig.mail_host.$error &&
v$.smtpConfig.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.mail_host"
:invalid="v$.smtpConfig.mail_host.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
@input="v$.smtpConfig.mail_host.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.username')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="smtpConfig.mail_username"
:content-loading="isFetchingInitialData"
type="text"
name="db_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.password')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="smtpConfig.mail_password"
:type="getInputType"
:content-loading="isFetchingInitialData"
autocomplete="off"
data-lpignore="true"
name="password"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.mail.port')"
:error="
v$.smtpConfig.mail_port.$error &&
v$.smtpConfig.mail_port.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseInput
v-model.trim="smtpConfig.mail_port"
:invalid="v$.smtpConfig.mail_port.$error"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
@input="v$.smtpConfig.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.encryption')"
:error="
v$.smtpConfig.mail_encryption.$error &&
v$.smtpConfig.mail_encryption.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model.trim="smtpConfig.mail_encryption"
:options="encryptions"
:can-deselect="false"
:invalid="v$.smtpConfig.mail_encryption.$error"
:content-loading="isFetchingInitialData"
@input="v$.smtpConfig.mail_encryption.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.mail.from_mail')"
:error="
v$.smtpConfig.from_mail.$error &&
v$.smtpConfig.from_mail.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseInput
v-model.trim="smtpConfig.from_mail"
:invalid="v$.smtpConfig.from_mail.$error"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
@input="v$.smtpConfig.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.mail.from_name')"
:error="
v$.smtpConfig.from_name.$error &&
v$.smtpConfig.from_name.$errors[0].$message
"
:content-loading="isFetchingInitialData"
required
>
<BaseInput
v-model.trim="smtpConfig.from_name"
:invalid="v$.smtpConfig.from_name.$error"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
@input="v$.smtpConfig.from_name.$touch()"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
const props = defineProps({
isSaving: {
type: Boolean,
require: true,
default: false,
},
isFetchingInitialData: {
type: Boolean,
require: true,
default: false,
},
})
const emit = defineEmits(['submit-data', 'on-change-driver'])
let isShowPassword = ref(false)
const encryptions = reactive(['tls', 'ssl', 'starttls'])
const { t } = useI18n()
const mailDriverStore = useMailDriverStore()
const smtpConfig = computed(() => {
return mailDriverStore.smtpConfig
})
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
smtpConfig.value.mail_driver = 'smtp'
const rules = computed(() => {
return {
smtpConfig: {
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
mail_encryption: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => mailDriverStore)
)
async function saveEmailConfig() {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', mailDriverStore.smtpConfig)
}
return false
}
function onChangeDriver() {
v$.value.smtpConfig.mail_driver.$touch()
emit('on-change-driver', mailDriverStore.smtpConfig.mail_driver)
}
</script>

View File

@ -0,0 +1,539 @@
<template>
<BasePage>
<SendInvoiceModal />
<BasePageHeader :title="$t('invoices.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$tc('invoices.invoice', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="invoiceStore.invoiceTotalCount"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FilterIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_INVOICE)"
to="invoices/create"
>
<BaseButton variant="primary" class="ml-4">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('invoices.new_invoice') }}
</BaseButton>
</router-link>
</template>
</BasePageHeader>
<BaseFilterWrapper
v-show="showFilters"
:row-on-xl="true"
@clear="clearFilter"
>
<BaseInputGroup :label="$tc('customers.customer', 1)">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('invoices.status')">
<BaseMultiselect
v-model="filters.status"
:groups="true"
:options="status"
searchable
:placeholder="$t('general.select_a_status')"
@update:modelValue="setActiveTab"
@remove="clearStatusSearch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('general.to')" class="mt-2">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('invoices.invoice_number')">
<BaseInput v-model="filters.invoice_number">
<template #left="slotProps">
<BaseIcon name="HashtagIcon" :class="slotProps.class" />
</template>
</BaseInput>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('invoices.no_invoices')"
:description="$t('invoices.list_of_invoices')"
>
<MoonwalkerIcon class="mt-5 mb-4" />
<template
v-if="userStore.hasAbilities(abilities.CREATE_INVOICE)"
#actions
>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/invoices/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('invoices.add_new_invoice') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<div
class="
relative
flex
items-center
justify-between
h-10
mt-5
list-none
border-b-2 border-gray-200 border-solid
"
>
<!-- Tabs -->
<BaseTabGroup class="-mb-5" @change="setStatusFilter">
<BaseTab :title="$t('general.draft')" filter="DRAFT" />
<BaseTab :title="$t('general.due')" filter="DUE" />
<BaseTab :title="$t('general.sent')" filter="SENT" />
<BaseTab :title="$t('general.all')" filter="" />
</BaseTabGroup>
<BaseDropdown
v-if="
invoiceStore.selectedInvoices.length &&
userStore.hasAbilities(abilities.DELETE_INVOICE)
"
class="absolute float-right"
>
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleInvoices">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="table"
:data="fetchData"
:columns="invoiceColumns"
:placeholder-count="invoiceStore.invoiceTotalCount >= 20 ? 10 : 5"
class="mt-10"
>
<!-- Select All Checkbox -->
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="invoiceStore.selectAllField"
variant="primary"
@change="invoiceStore.selectAllInvoices"
/>
</div>
</template>
<template #cell-checkbox="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
/>
</div>
</template>
<template #cell-name="{ row }">
<BaseText :text="row.data.customer.name" :length="30" />
</template>
<!-- Invoice Number -->
<template #cell-invoice_number="{ row }">
<router-link
:to="{ path: `invoices/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.invoice_number }}
</router-link>
</template>
<!-- Invoice date -->
<template #cell-invoice_date="{ row }">
{{ row.data.formatted_invoice_date }}
</template>
<!-- Invoice Total -->
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<!-- Invoice status -->
<template #cell-status="{ row }">
<BaseInvoiceStatusBadge :status="row.data.status" class="px-3 py-1">
{{ row.data.status }}
</BaseInvoiceStatusBadge>
</template>
<!-- Due Amount + Paid Status -->
<template #cell-due_amount="{ row }">
<div class="flex justify-between">
<BaseFormatMoney
:amount="row.data.due_amount"
:currency="row.data.currency"
/>
<BasePaidStatusBadge
:status="row.data.paid_status"
class="px-1 py-0.5 ml-2"
>
{{ row.data.paid_status }}
</BasePaidStatusBadge>
</div>
</template>
<!-- Actions -->
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<InvoiceDropdown :row="row.data" :table="table" />
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import { debouncedWatch } from '@vueuse/core'
import MoonwalkerIcon from '@/scripts/components/icons/empty/MoonwalkerIcon.vue'
import InvoiceDropdown from '@/scripts/admin/components/dropdowns/InvoiceIndexDropdown.vue'
import SendInvoiceModal from '@/scripts/admin/components/modal-components/SendInvoiceModal.vue'
// Stores
const invoiceStore = useInvoiceStore()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
// Local State
const utils = inject('$utils')
const table = ref(null)
const showFilters = ref(false)
const status = ref([
{
label: 'Status',
options: ['DRAFT', 'DUE', 'SENT', 'VIEWED', 'OVERDUE', 'COMPLETED'],
},
{
label: 'Paid Status',
options: ['UNPAID', 'PAID', 'PARTIALLY_PAID'],
},
,
])
const isRequestOngoing = ref(true)
const activeTab = ref('general.draft')
const router = useRouter()
const userStore = useUserStore()
let filters = reactive({
customer_id: '',
status: 'DRAFT',
from_date: '',
to_date: '',
invoice_number: '',
})
const showEmptyScreen = computed(
() => !invoiceStore.invoiceTotalCount && !isRequestOngoing.value
)
const selectField = computed({
get: () => invoiceStore.selectedInvoices,
set: (value) => {
return invoiceStore.selectInvoice(value)
},
})
const invoiceColumns = computed(() => {
return [
{
key: 'checkbox',
thClass: 'extra w-10',
tdClass: 'font-medium text-gray-900',
placeholderClass: 'w-10',
sortable: false,
},
{
key: 'invoice_date',
label: t('invoices.date'),
thClass: 'extra',
tdClass: 'font-medium',
},
{ key: 'invoice_number', label: t('invoices.number') },
{ key: 'name', label: t('invoices.customer') },
{ key: 'status', label: t('invoices.status') },
{
key: 'due_amount',
label: t('dashboard.recent_invoices_card.amount_due'),
},
{
key: 'total',
label: t('invoices.total'),
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: t('invoices.action'),
tdClass: 'text-right text-sm font-medium',
thClass: 'text-right',
sortable: false,
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (invoiceStore.selectAllField) {
invoiceStore.selectAllInvoices()
}
})
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_INVOICE,
abilities.EDIT_INVOICE,
abilities.VIEW_INVOICE,
abilities.SEND_INVOICE,
])
}
async function clearStatusSearch(removedOption, id) {
filters.status = ''
refreshTable()
}
function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
customer_id: filters.customer_id,
status: filters.status,
from_date: filters.from_date,
to_date: filters.to_date,
invoice_number: filters.invoice_number,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isRequestOngoing.value = true
let response = await invoiceStore.fetchInvoices(data)
isRequestOngoing.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function setStatusFilter(val) {
if (activeTab.value == val.title) {
return true
}
activeTab.value = val.title
switch (val.title) {
case t('general.draft'):
filters.status = 'DRAFT'
break
case t('general.sent'):
filters.status = 'SENT'
break
case t('general.due'):
filters.status = 'DUE'
break
default:
filters.status = ''
break
}
}
function setFilters() {
invoiceStore.$patch((state) => {
state.selectedInvoices = []
state.selectAllField = false
})
refreshTable()
}
function clearFilter() {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.invoice_number = ''
activeTab.value = t('general.all')
}
async function removeMultipleInvoices() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('invoices.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
await invoiceStore.deleteMultipleInvoices().then((res) => {
if (res.data.success) {
refreshTable()
invoiceStore.$patch((state) => {
state.selectedInvoices = []
state.selectAllField = false
})
}
})
}
})
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function setActiveTab(val) {
switch (val) {
case 'DRAFT':
activeTab.value = t('general.draft')
break
case 'SENT':
activeTab.value = t('general.sent')
break
case 'DUE':
activeTab.value = t('general.due')
break
case 'COMPLETED':
activeTab.value = t('invoices.completed')
break
case 'PAID':
activeTab.value = t('invoices.paid')
break
case 'UNPAID':
activeTab.value = t('invoices.unpaid')
break
case 'PARTIALLY_PAID':
activeTab.value = t('invoices.partially_paid')
break
case 'VIEWED':
activeTab.value = t('invoices.viewed')
break
case 'OVERDUE':
activeTab.value = t('invoices.overdue')
break
default:
activeTab.value = t('general.all')
break
}
}
</script>

View File

@ -0,0 +1,483 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, reactive, ref, watch, inject } from 'vue'
import InvoiceDropdown from '@/scripts/admin/components/dropdowns/InvoiceIndexDropdown.vue'
import { useRoute, useRouter } from 'vue-router'
import { debounce } from 'lodash'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useModalStore } from '@/scripts/stores/modal'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useDialogStore } from '@/scripts/stores/dialog'
import SendInvoiceModal from '@/scripts/admin/components/modal-components/SendInvoiceModal.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import abilities from '@/scripts/admin/stub/abilities'
const modalStore = useModalStore()
const invoiceStore = useInvoiceStore()
const notificationStore = useNotificationStore()
const userStore = useUserStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const utils = inject('$utils')
const id = ref(null)
const count = ref(null)
const invoiceData = ref(null)
const currency = ref(null)
const route = useRoute()
const router = useRouter()
const status = ref([
'DRAFT',
'SENT',
'VIEWED',
'EXPIRED',
'ACCEPTED',
'REJECTED',
])
const isMarkAsSent = ref(false)
const isSendingEmail = ref(false)
const isRequestOnGoing = ref(false)
const isSearching = ref(false)
const isLoading = ref(false)
const searchData = reactive({
orderBy: null,
orderByField: null,
searchText: null,
})
const pageTitle = computed(() => invoiceData.value.invoice_number)
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() => {
if (getOrderBy.value) {
return t('general.ascending')
}
return t('general.descending')
})
const shareableLink = computed(() => {
return `/invoices/pdf/${invoiceData.value.unique_hash}`
})
const getCurrentInvoiceId = computed(() => {
if (invoiceData.value && invoiceData.value.id) {
return invoice.value.id
}
return null
})
watch(route, (to, from) => {
if (to.name === 'invoices.view') {
loadInvoice()
}
})
async function onMarkAsSent() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('invoices.invoice_mark_as_sent'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'primary',
hideNoButton: false,
size: 'lg',
})
.then(async (response) => {
isMarkAsSent.value = false
if (response) {
await invoiceStore.markAsSent({
id: invoiceData.value.id,
status: 'SENT',
})
invoiceData.value.status = 'SENT'
isMarkAsSent.value = true
}
isMarkAsSent.value = false
})
}
async function onSendInvoice(id) {
modalStore.openModal({
title: t('invoices.send_invoice'),
componentName: 'SendInvoiceModal',
id: invoiceData.value.id,
data: invoiceData.value,
})
}
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadInvoices() {
isLoading.value = true
await invoiceStore.fetchInvoices()
isLoading.value = false
setTimeout(() => {
scrollToInvoice()
}, 500)
}
function scrollToInvoice() {
const el = document.getElementById(`invoice-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function loadInvoice() {
let response = await invoiceStore.fetchInvoice(route.params.id)
if (response.data) {
invoiceData.value = { ...response.data.data }
}
}
async function onSearched() {
let data = ''
if (
searchData.searchText !== '' &&
searchData.searchText !== null &&
searchData.searchText !== undefined
) {
data += `search=${searchData.searchText}&`
}
if (searchData.orderBy !== null && searchData.orderBy !== undefined) {
data += `orderBy=${searchData.orderBy}&`
}
if (
searchData.orderByField !== null &&
searchData.orderByField !== undefined
) {
data += `orderByField=${searchData.orderByField}`
}
isSearching.value = true
let response = await invoiceStore.searchInvoice(data)
isSearching.value = false
if (response.data) {
invoiceStore.invoices = response.data.data
}
}
function sortData() {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
onSearched()
return true
}
searchData.orderBy = 'asc'
onSearched()
return true
}
loadInvoices()
loadInvoice()
onSearched = debounce(onSearched, 500)
</script>
<template>
<SendInvoiceModal />
<BasePage v-if="invoiceData" class="xl:pl-96 xl:ml-8">
<BasePageHeader :title="pageTitle">
<template #actions>
<div class="text-sm mr-3">
<BaseButton
v-if="
invoiceData.status === 'DRAFT' &&
userStore.hasAbilities(abilities.EDIT_INVOICE)
"
:disabled="isMarkAsSent"
variant="primary-outline"
@click="onMarkAsSent"
>
{{ $t('invoices.mark_as_sent') }}
</BaseButton>
</div>
<BaseButton
v-if="
invoiceData.status === 'DRAFT' &&
userStore.hasAbilities(abilities.SEND_INVOICE)
"
:disabled="isSendingEmail"
variant="primary"
class="text-sm"
@click="onSendInvoice"
>
{{ $t('invoices.send_invoice') }}
</BaseButton>
<!-- Record Payment -->
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_PAYMENT)"
:to="`/admin/payments/${$route.params.id}/create`"
>
<BaseButton
v-if="
invoiceData.status === 'SENT' ||
invoiceData.status === 'OVERDUE' ||
invoiceData.status === 'VIEWED'
"
variant="primary"
>
{{ $t('invoices.record_payment') }}
</BaseButton>
</router-link>
<!-- Invoice Dropdown -->
<InvoiceDropdown
class="ml-3"
:row="invoiceData"
:load-data="loadInvoices"
/>
</template>
</BasePageHeader>
<!-- sidebar -->
<div
class="
fixed
top-0
left-0
hidden
h-full
pt-16
pb-4
ml-56
bg-white
xl:ml-64
w-88
xl:block
"
>
<div
class="
flex
items-center
justify-between
px-4
pt-8
pb-2
border border-gray-200 border-solid
height-full
"
>
<div class="mb-6">
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearched()"
>
<template #right>
<BaseIcon name="SearchIcon" class="h-5 text-gray-400" />
</template>
</BaseInput>
</div>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<BaseDropdown class="ml-3" position="bottom-start">
<template #activator>
<BaseButton size="md" variant="gray">
<BaseIcon name="FilterIcon" />
</BaseButton>
</template>
<div
class="
px-2
py-1
pb-2
mb-1 mb-2
text-sm
border-b border-gray-200 border-solid
"
>
{{ $t('general.sort_by') }}
</div>
<BaseDropdownItem class="flex px-1 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_invoice_date"
v-model="searchData.orderByField"
:label="$t('reports.invoices.invoice_date')"
size="sm"
name="filter"
value="invoice_date"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
<BaseDropdownItem class="flex px-1 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_due_date"
v-model="searchData.orderByField"
:label="$t('invoices.due_date')"
value="due_date"
size="sm"
name="filter"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
<BaseDropdownItem class="flex px-1 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_invoice_number"
v-model="searchData.orderByField"
:label="$t('invoices.invoice_number')"
value="invoice_number"
size="sm"
name="filter"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" />
<BaseIcon v-else name="SortDescendingIcon" />
</BaseButton>
</div>
</div>
<div
v-if="invoiceStore && invoiceStore.invoices"
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
base-scroll
"
>
<div v-for="(invoice, index) in invoiceStore.invoices" :key="index">
<router-link
v-if="invoice && !isLoading"
:id="'invoice-' + invoice.id"
:to="`/admin/invoices/${invoice.id}/view`"
:class="[
'flex justify-between side-invoice p-4 cursor-pointer hover:bg-gray-100 items-center border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid':
hasActiveUrl(invoice.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<BaseText
:text="invoice.customer.name"
:length="30"
class="
pr-2
mb-2
text-sm
not-italic
font-normal
leading-5
text-black
capitalize
truncate
"
/>
<div
class="
mt-1
mb-2
text-xs
not-italic
font-medium
leading-5
text-gray-600
"
>
{{ invoice.invoice_number }}
</div>
<BaseEstimateStatusBadge
:status="invoice.status"
class="px-1 text-xs"
>
{{ invoice.status }}
</BaseEstimateStatusBadge>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
class="
mb-2
text-xl
not-italic
font-semibold
leading-8
text-right text-gray-900
block
"
:amount="invoice.total"
:currency="invoice.customer.currency"
/>
<div
class="
text-sm
not-italic
font-normal
leading-5
text-right text-gray-600
est-date
"
>
{{ invoice.formatted_invoice_date }}
</div>
</div>
</router-link>
</div>
<div class="flex justify-center p-4 items-center">
<LoadingIcon
v-if="isLoading"
class="h-6 m-1 animate-spin text-primary-400"
/>
</div>
<p
v-if="!invoiceStore.invoices.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('invoices.no_matching_invoices') }}
</p>
</div>
</div>
<div
class="flex flex-col min-h-0 mt-8 overflow-hidden"
style="height: 75vh"
>
<iframe
:src="`${shareableLink}`"
class="
flex-1
border border-gray-400 border-solid
bg-white
rounded-md
frame-style
"
/>
</div>
</BasePage>
</template>

View File

@ -0,0 +1,280 @@
<template>
<SelectTemplateModal />
<ItemModal />
<TaxTypeModal />
<SalesTax
v-if="salesTaxEnabled && (!isLoadingContent || route.query.customer)"
:store="invoiceStore"
:is-edit="isEdit"
store-prop="newInvoice"
:customer="invoiceStore.newInvoice.customer"
/>
<BasePage class="relative invoice-create-page">
<form @submit.prevent="submitForm">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$tc('invoices.invoice', 2)"
to="/admin/invoices"
/>
<BaseBreadcrumbItem
v-if="$route.name === 'invoices.edit'"
:title="$t('invoices.edit_invoice')"
to="#"
active
/>
<BaseBreadcrumbItem
v-else
:title="$t('invoices.new_invoice')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<router-link
v-if="$route.name === 'invoices.edit'"
:to="`/invoices/pdf/${invoiceStore.newInvoice.unique_hash}`"
target="_blank"
>
<BaseButton class="mr-3" variant="primary-outline" type="button">
<span class="flex">
{{ $t('general.view_pdf') }}
</span>
</BaseButton>
</router-link>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{ $t('invoices.save_invoice') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Select Customer & Basic Fields -->
<InvoiceBasicFields
:v="v$"
:is-loading="isLoadingContent"
:is-edit="isEdit"
/>
<BaseScrollPane>
<!-- Invoice Items -->
<InvoiceItems
:currency="invoiceStore.newInvoice.selectedCurrency"
:is-loading="isLoadingContent"
:item-validation-scope="invoiceValidationScope"
:store="invoiceStore"
store-prop="newInvoice"
/>
<!-- Invoice Footer Section -->
<div
class="
block
mt-10
invoice-foot
lg:flex lg:justify-between lg:items-start
"
>
<div class="relative w-full lg:w-1/2 lg:mr-4">
<!-- Invoice Custom Notes -->
<NoteFields
:store="invoiceStore"
store-prop="newInvoice"
:fields="invoiceNoteFieldList"
type="Invoice"
/>
<!-- Invoice Custom Fields -->
<InvoiceCustomFields
type="Invoice"
:is-edit="isEdit"
:is-loading="isLoadingContent"
:store="invoiceStore"
store-prop="newInvoice"
:custom-field-scope="invoiceValidationScope"
class="mb-6"
/>
<!-- Invoice Template Button-->
<SelectTemplate
:store="invoiceStore"
store-prop="newInvoice"
component-name="InvoiceTemplate"
/>
</div>
<InvoiceTotal
:currency="invoiceStore.newInvoice.selectedCurrency"
:is-loading="isLoadingContent"
:store="invoiceStore"
store-prop="newInvoice"
tax-popup-type="invoice"
/>
</div>
</BaseScrollPane>
</form>
</BasePage>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
maxLength,
helpers,
requiredIf,
decimal,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useModuleStore } from '@/scripts/admin/stores/module'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import InvoiceItems from '@/scripts/admin/components/estimate-invoice-common/CreateItems.vue'
import InvoiceTotal from '@/scripts/admin/components/estimate-invoice-common/CreateTotal.vue'
import SelectTemplate from '@/scripts/admin/components/estimate-invoice-common/SelectTemplateButton.vue'
import InvoiceBasicFields from './InvoiceCreateBasicFields.vue'
import InvoiceCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import NoteFields from '@/scripts/admin/components/estimate-invoice-common/CreateNotesField.vue'
import SelectTemplateModal from '@/scripts/admin/components/modal-components/SelectTemplateModal.vue'
import TaxTypeModal from '@/scripts/admin/components/modal-components/TaxTypeModal.vue'
import ItemModal from '@/scripts/admin/components/modal-components/ItemModal.vue'
import SalesTax from '@/scripts/admin/components/estimate-invoice-common/SalesTax.vue'
const invoiceStore = useInvoiceStore()
const companyStore = useCompanyStore()
const customFieldStore = useCustomFieldStore()
const moduleStore = useModuleStore()
const { t } = useI18n()
let route = useRoute()
let router = useRouter()
const invoiceValidationScope = 'newInvoice'
let isSaving = ref(false)
const invoiceNoteFieldList = ref([
'customer',
'company',
'customerCustom',
'invoice',
'invoiceCustom',
])
let isLoadingContent = computed(
() => invoiceStore.isFetchingInvoice || invoiceStore.isFetchingInitialSettings
)
let pageTitle = computed(() =>
isEdit.value ? t('invoices.edit_invoice') : t('invoices.new_invoice')
)
const salesTaxEnabled = computed(() => {
return (
companyStore.selectedCompanySettings.sales_tax_us_enabled === 'YES' &&
moduleStore.salesTaxUSEnabled
)
})
let isEdit = computed(() => route.name === 'invoices.edit')
const rules = {
invoice_date: {
required: helpers.withMessage(t('validation.required'), required),
},
reference_number: {
maxLength: helpers.withMessage(
t('validation.price_maxlength'),
maxLength(255)
),
},
customer_id: {
required: helpers.withMessage(t('validation.required'), required),
},
invoice_number: {
required: helpers.withMessage(t('validation.required'), required),
},
exchange_rate: {
required: requiredIf(function () {
helpers.withMessage(t('validation.required'), required)
return invoiceStore.showExchangeRate
}),
decimal: helpers.withMessage(t('validation.valid_exchange_rate'), decimal),
},
}
const v$ = useVuelidate(
rules,
computed(() => invoiceStore.newInvoice),
{ $scope: invoiceValidationScope }
)
customFieldStore.resetCustomFields()
v$.value.$reset
invoiceStore.resetCurrentInvoice()
invoiceStore.fetchInvoiceInitialSettings(isEdit.value)
watch(
() => invoiceStore.newInvoice.customer,
(newVal) => {
if (newVal && newVal.currency) {
invoiceStore.newInvoice.selectedCurrency = newVal.currency
} else {
invoiceStore.newInvoice.selectedCurrency =
companyStore.selectedCompanyCurrency
}
}
)
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return false
}
isSaving.value = true
let data = {
...invoiceStore.newInvoice,
sub_total: invoiceStore.getSubTotal,
total: invoiceStore.getTotal,
tax: invoiceStore.getTotalTax,
}
try {
const action = isEdit.value
? invoiceStore.updateInvoice
: invoiceStore.addInvoice
const response = await action(data)
router.push(`/admin/invoices/${response.data.data.id}/view`)
} catch (err) {
console.error(err)
}
isSaving.value = false
}
</script>

View File

@ -0,0 +1,83 @@
<template>
<div class="grid grid-cols-12 gap-8 mt-6 mb-8">
<BaseCustomerSelectPopup
v-model="invoiceStore.newInvoice.customer"
:valid="v.customer_id"
:content-loading="isLoading"
type="invoice"
class="col-span-12 lg:col-span-5 pr-0"
/>
<BaseInputGrid class="col-span-12 lg:col-span-7">
<BaseInputGroup
:label="$t('invoices.invoice_date')"
:content-loading="isLoading"
required
:error="v.invoice_date.$error && v.invoice_date.$errors[0].$message"
>
<BaseDatePicker
v-model="invoiceStore.newInvoice.invoice_date"
:content-loading="isLoading"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('invoices.due_date')"
:content-loading="isLoading"
>
<BaseDatePicker
v-model="invoiceStore.newInvoice.due_date"
:content-loading="isLoading"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('invoices.invoice_number')"
:content-loading="isLoading"
:error="v.invoice_number.$error && v.invoice_number.$errors[0].$message"
required
>
<BaseInput
v-model="invoiceStore.newInvoice.invoice_number"
:content-loading="isLoading"
@input="v.invoice_number.$touch()"
/>
</BaseInputGroup>
<ExchangeRateConverter
:store="invoiceStore"
store-prop="newInvoice"
:v="v"
:is-loading="isLoading"
:is-edit="isEdit"
:customer-currency="invoiceStore.newInvoice.currency_id"
/>
</BaseInputGrid>
</div>
</template>
<script setup>
import ExchangeRateConverter from '@/scripts/admin/components/estimate-invoice-common/ExchangeRateConverter.vue'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
const props = defineProps({
v: {
type: Object,
default: null,
},
isLoading: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
})
const invoiceStore = useInvoiceStore()
</script>

View File

@ -0,0 +1,312 @@
<template>
<BasePage>
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$tc('items.item', 2)" to="/admin/items" />
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<ItemUnitModal />
<form
class="grid lg:grid-cols-2 mt-6"
action="submit"
@submit.prevent="submitItem"
>
<BaseCard class="w-full">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('items.name')"
:content-loading="isFetchingInitialData"
required
:error="
v$.currentItem.name.$error &&
v$.currentItem.name.$errors[0].$message
"
>
<BaseInput
v-model="itemStore.currentItem.name"
:content-loading="isFetchingInitialData"
:invalid="v$.currentItem.name.$error"
@input="v$.currentItem.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('items.price')"
:content-loading="isFetchingInitialData"
>
<BaseMoney
v-model="price"
:content-loading="isFetchingInitialData"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('items.unit')"
>
<BaseMultiselect
v-model="itemStore.currentItem.unit_id"
:content-loading="isFetchingInitialData"
label="name"
:options="itemStore.itemUnits"
value-prop="id"
:can-deselect="false"
:can-clear="false"
:placeholder="$t('items.select_a_unit')"
searchable
track-by="name"
>
<template #action>
<BaseSelectAction @click="addItemUnit">
<BaseIcon
name="PlusIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('settings.customization.items.add_item_unit') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
v-if="isTaxPerItem"
:label="$t('items.taxes')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="taxes"
:content-loading="isFetchingInitialData"
:options="getTaxTypes"
mode="tags"
label="tax_name"
class="w-full"
value-prop="id"
:can-deselect="false"
:can-clear="false"
searchable
track-by="tax_name"
object
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('items.description')"
:content-loading="isFetchingInitialData"
:error="
v$.currentItem.description.$error &&
v$.currentItem.description.$errors[0].$message
"
>
<BaseTextarea
v-model="itemStore.currentItem.description"
:content-loading="isFetchingInitialData"
name="description"
:row="2"
rows="2"
@input="v$.currentItem.description.$touch()"
/>
</BaseInputGroup>
<div>
<BaseButton
:content-loading="isFetchingInitialData"
type="submit"
:loading="isSaving"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('items.update_item') : $t('items.save_item') }}
</BaseButton>
</div>
</BaseInputGrid>
</BaseCard>
</form>
</BasePage>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
numeric,
minValue,
maxLength,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useItemStore } from '@/scripts/admin/stores/item'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
import { useModalStore } from '@/scripts/stores/modal'
import ItemUnitModal from '@/scripts/admin/components/modal-components/ItemUnitModal.vue'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
const itemStore = useItemStore()
const taxTypeStore = useTaxTypeStore()
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isSaving = ref(false)
const taxPerItem = ref(companyStore.selectedCompanySettings.tax_per_item)
let isFetchingInitialData = ref(false)
itemStore.$reset()
loadData()
const price = computed({
get: () => itemStore.currentItem.price / 100,
set: (value) => {
itemStore.currentItem.price = Math.round(value * 100)
},
})
const taxes = computed({
get: () =>
itemStore?.currentItem?.taxes?.map((tax) => {
if (tax) {
return {
...tax,
tax_type_id: tax.id,
tax_name: tax.name + ' (' + tax.percent + '%)',
}
}
}),
set: (value) => {
itemStore.currentItem.taxes = value
},
})
const isEdit = computed(() => route.name === 'items.edit')
const pageTitle = computed(() =>
isEdit.value ? t('items.edit_item') : t('items.new_item')
)
const getTaxTypes = computed(() => {
return taxTypeStore.taxTypes.map((tax) => {
return {
...tax,
tax_type_id: tax.id,
tax_name: tax.name + ' (' + tax.percent + '%)',
}
})
})
const isTaxPerItem = computed(() => taxPerItem.value === 'YES')
const rules = computed(() => {
return {
currentItem: {
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'),
maxLength(65000)
),
},
},
}
})
const v$ = useVuelidate(rules, itemStore)
async function addItemUnit() {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
size: 'sm',
})
}
async function loadData() {
isFetchingInitialData.value = true
await itemStore.fetchItemUnits({ limit: 'all' })
if (userStore.hasAbilities(abilities.VIEW_TAX_TYPE)) {
await taxTypeStore.fetchTaxTypes({ limit: 'all' })
}
if (isEdit.value) {
let id = route.params.id
await itemStore.fetchItem(id)
itemStore.currentItem.tax_per_item === 1
? (taxPerItem.value = 'YES')
: (taxPerItem.value = 'NO')
}
isFetchingInitialData.value = false
}
async function submitItem() {
v$.value.currentItem.$touch()
if (v$.value.currentItem.$invalid) {
return false
}
isSaving.value = true
try {
let data = {
id: route.params.id,
...itemStore.currentItem,
}
if (itemStore.currentItem && itemStore.currentItem.taxes) {
data.taxes = itemStore.currentItem.taxes.map((tax) => {
return {
tax_type_id: tax.tax_type_id,
amount: price.value * tax.percent,
percent: tax.percent,
name: tax.name,
collective_tax: 0,
}
})
}
const action = isEdit.value ? itemStore.updateItem : itemStore.addItem
await action(data)
isSaving.value = false
router.push('/admin/items')
closeItemModal()
} catch (err) {
isSaving.value = false
return
}
function closeItemModal() {
modalStore.closeModal()
setTimeout(() => {
itemStore.resetCurrentItem()
modalStore.$reset()
v$.value.$reset()
}, 300)
}
}
</script>

View File

@ -0,0 +1,359 @@
<template>
<BasePage>
<BasePageHeader :title="$t('items.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$tc('items.item', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton
v-show="itemStore.totalItems"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
:class="slotProps.class"
name="FilterIcon"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_ITEM)"
@click="$router.push('/admin/items/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('items.add_item') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-5" @clear="clearFilter">
<BaseInputGroup :label="$tc('items.name')" class="text-left">
<BaseInput
v-model="filters.name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$tc('items.unit')" class="text-left">
<BaseMultiselect
v-model="filters.unit_id"
:placeholder="$t('items.select_a_unit')"
value-prop="id"
track-by="name"
:filter-results="false"
label="name"
resolve-on-load
:delay="500"
searchable
class="w-full"
:options="searchUnits"
/>
</BaseInputGroup>
<BaseInputGroup class="text-left" :label="$tc('items.price')">
<BaseMoney v-model="filters.price" />
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('items.no_items')"
:description="$t('items.list_of_items')"
>
<SatelliteIcon class="mt-5 mb-4" />
<template #actions>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_ITEM)"
variant="primary-outline"
@click="$router.push('/admin/items/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('items.add_new_item') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<div
class="
relative
flex
items-center
justify-end
h-5
border-gray-200 border-solid
"
>
<BaseDropdown v-if="itemStore.selectedItems.length">
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleItems">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="table"
:data="fetchData"
:columns="itemColumns"
:placeholder-count="itemStore.totalItems >= 20 ? 10 : 5"
class="mt-3"
>
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="itemStore.selectAllField"
variant="primary"
@change="itemStore.selectAllItems"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
/>
</div>
</template>
<template #cell-name="{ row }">
<router-link
:to="{ path: `items/${row.data.id}/edit` }"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-unit_name="{ row }">
<span>
{{ row.data.unit ? row.data.unit.name : '-' }}
</span>
</template>
<template #cell-price="{ row }">
<BaseFormatMoney
:amount="row.data.price"
:currency="companyStore.selectedCompanyCurrency"
/>
</template>
<template #cell-created_at="{ row }">
<span>{{ row.data.formatted_created_at }}</span>
</template>
<template v-if="hasAbilities()" #cell-actions="{ row }">
<ItemDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { ref, computed, inject, onMounted, reactive, onUnmounted } from 'vue'
import { debouncedWatch } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useItemStore } from '@/scripts/admin/stores/item'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
import ItemDropdown from '@/scripts/admin/components/dropdowns/ItemIndexDropdown.vue'
import SatelliteIcon from '@/scripts/components/icons/empty/SatelliteIcon.vue'
import abilities from '@/scripts/admin/stub/abilities'
const utils = inject('utils')
const itemStore = useItemStore()
const companyStore = useCompanyStore()
const notificationStore = useNotificationStore()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const { t } = useI18n()
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
const filters = reactive({
name: '',
unit_id: '',
price: '',
})
const table = ref(null)
const showEmptyScreen = computed(
() => !itemStore.totalItems && !isFetchingInitialData.value
)
const selectField = computed({
get: () => itemStore.selectedItems,
set: (value) => {
return itemStore.selectItem(value)
},
})
const itemColumns = computed(() => {
return [
{
key: 'status',
thClass: 'extra w-10',
tdClass: 'font-medium text-gray-900',
placeholderClass: 'w-10',
sortable: false,
},
{
key: 'name',
label: t('items.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'unit_name', label: t('items.unit') },
{ key: 'price', label: t('items.price') },
{ key: 'created_at', label: t('items.added_on') },
{
key: 'actions',
thClass: 'text-right',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
itemStore.fetchItemUnits({ limit: 'all' })
onUnmounted(() => {
if (itemStore.selectAllField) {
itemStore.selectAllItems()
}
})
function clearFilter() {
filters.name = ''
filters.unit_id = ''
filters.price = ''
}
function hasAbilities() {
return userStore.hasAbilities([abilities.DELETE_ITEM, abilities.EDIT_ITEM])
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function refreshTable() {
table.value && table.value.refresh()
}
function setFilters() {
refreshTable()
}
async function searchUnits(search) {
let res = await itemStore.fetchItemUnits({ search })
return res.data.data
}
async function fetchData({ page, filter, sort }) {
let data = {
search: filters.name,
unit_id: filters.unit_id !== null ? filters.unit_id : '',
price: Math.round(filters.price * 100),
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await itemStore.fetchItems(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function removeMultipleItems() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('items.confirm_delete', 2),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
itemStore.deleteMultipleItems().then((response) => {
if (response.data.success) {
table.value && table.value.refresh()
}
})
}
})
}
</script>

View File

@ -0,0 +1,230 @@
<template>
<BasePage>
<BasePageHeader :title="$t('modules.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$tc('modules.module', 2)" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<!-- Modules Section -->
<div v-if="hasApiToken && moduleStore.modules">
<BaseTabGroup class="-mb-5" @change="setStatusFilter">
<BaseTab :title="$t('general.all')" filter="" />
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
</BaseTabGroup>
<!-- Modules Card Placeholder -->
<div
v-if="isFetchingModule"
class="
grid
mt-6
w-full
grid-cols-1
items-start
gap-6
lg:grid-cols-2
xl:grid-cols-3
"
>
<ModuleCardPlaceholder />
<ModuleCardPlaceholder />
<ModuleCardPlaceholder />
</div>
<!-- Modules Card -->
<div v-else>
<div
v-if="modules && modules.length"
class="
grid
mt-6
w-full
grid-cols-1
items-start
gap-6
lg:grid-cols-2
xl:grid-cols-3
"
>
<div v-for="(moduleData, idx) in modules" :key="idx">
<ModuleCard :data="moduleData" />
</div>
</div>
<div v-else class="mt-24">
<label class="flex items-center justify-center text-gray-500">
{{ $t('modules.no_modules_installed') }}
</label>
</div>
</div>
</div>
<BaseCard v-else class="mt-6">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('modules.connect_installation') }}
</h6>
<p class="mt-1 text-sm text-gray-500">
{{
$t('modules.api_token_description', {
url: globalStore.config.base_url.replace(/^http:\/\//, ''),
})
}}
</p>
<!-- Api Token Form -->
<div class="grid lg:grid-cols-2 mt-6">
<form action="" class="mt-6" @submit.prevent="submitApiToken">
<BaseInputGroup
:label="$t('modules.api_token')"
required
:error="v$.api_token.$error && v$.api_token.$errors[0].$message"
>
<BaseInput
v-model="moduleStore.currentUser.api_token"
:invalid="v$.api_token.$error"
@input="v$.api_token.$touch()"
/>
</BaseInputGroup>
<div class="flex space-x-2">
<BaseButton class="mt-6" :loading="isSaving" type="submit">
<template #left="slotProps">
<BaseIcon name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
<a
:href="`${globalStore.config.base_url}/auth/customer/register`"
class="mt-6 block"
target="_blank"
>
<BaseButton variant="primary-outline" type="button">
Sign up & Get Token
</BaseButton>
</a>
</div>
</form>
</div>
</BaseCard>
</BasePage>
</template>
<script setup>
import { useModuleStore } from '@/scripts/admin/stores/module'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { computed, onMounted, reactive, ref, watchEffect } from 'vue'
import {
required,
minLength,
maxLength,
helpers,
requiredUnless,
} from '@vuelidate/validators'
import ModuleCard from './partials/ModuleCard.vue'
import ModuleCardPlaceholder from './partials/ModuleCardPlaceholder.vue'
import { useVuelidate } from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
const moduleStore = useModuleStore()
const globalStore = useGlobalStore()
const activeTab = ref('')
const { t } = useI18n()
let isSaving = ref(false)
let isFetchingModule = ref(false)
const rules = computed(() => {
return {
api_token: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
}
})
const hasApiToken = computed(() => {
if (moduleStore.apiToken) {
fetchModulesData()
return true
}
return false
})
const v$ = useVuelidate(
rules,
computed(() => moduleStore.currentUser)
)
const modules = computed(() => {
if (activeTab.value === 'INSTALLED') {
return moduleStore.modules.filter((_m) => _m.installed)
}
return moduleStore.modules
})
async function fetchModulesData() {
isFetchingModule.value = true
await moduleStore.fetchModules().then(() => {
isFetchingModule.value = false
})
}
async function submitApiToken() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
moduleStore
.checkApiToken(moduleStore.currentUser.api_token)
.then((response) => {
if (response.data.success) {
saveApiTokenToSettings()
return
}
isSaving.value = false
return
})
}
async function saveApiTokenToSettings() {
try {
await globalStore
.updateGlobalSettings({
data: {
settings: {
api_token: moduleStore.currentUser.api_token,
},
},
message: 'settings.preferences.updated_message',
})
.then((response) => {
if (response.data.success) {
moduleStore.apiToken = moduleStore.currentUser.api_token
return
}
})
isSaving.value = false
} catch (err) {
isSaving.value = false
console.error(err)
return
}
}
function setStatusFilter(data) {
activeTab.value = data.filter
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,140 @@
<template>
<div
class="
relative
shadow-md
border-2 border-gray-200 border-opacity-60
rounded-lg
cursor-pointer
overflow-hidden
h-100
"
@click="$router.push(`/admin/modules/${data.slug}`)"
>
<div
v-if="data.purchased"
class="absolute mt-5 px-6 w-full flex justify-end"
>
<label
v-if="data.purchased"
class="
bg-white bg-opacity-75
text-xs
px-3
py-1
font-semibold
tracking-wide
rounded
"
>
{{ $t('modules.purchased') }}
</label>
<label
v-if="data.installed"
class="
ml-2
bg-white bg-opacity-75
text-xs
px-3
py-1
font-semibold
tracking-wide
rounded
"
>
<span v-if="data.update_available">
{{ $t('modules.update_available') }}
</span>
<span v-else>
{{ $t('modules.installed') }}
</span>
</label>
</div>
<img
class="lg:h-64 md:h-48 w-full object-cover object-center"
:src="data.cover"
alt="cover"
/>
<div class="px-6 py-5 flex flex-col bg-gray-50 flex-1 justify-between">
<span
class="
text-lg
sm:text-2xl
font-medium
whitespace-nowrap
truncate
text-primary-500
"
>
{{ data.name }}
</span>
<div v-if="data.author_avatar" class="flex items-center mt-2">
<img
class="hidden h-10 w-10 rounded-full sm:inline-block mr-2"
:src="
data.author_avatar
? data.author_avatar
: 'http://localhost:3000/img/default-avatar.jpg'
"
alt=""
/>
<span>by</span>
<span class="ml-2 text-base font-semibold truncate"
>{{ data.author_name }}
</span>
</div>
<base-text
:text="data.short_description"
class="pt-4 text-gray-500 h-16 line-clamp-2"
:length="110"
>
</base-text>
<div
class="
flex
justify-between
mt-4
flex-col
space-y-2
sm:space-y-0 sm:flex-row
"
>
<div><BaseRating :rating="averageRating" /></div>
<div
class="
text-xl
md:text-2xl
font-semibold
whitespace-nowrap
text-primary-500
"
>
$
{{
data.monthly_price
? data.monthly_price / 100
: data.yearly_price / 100
}}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref, watch, reactive } from 'vue'
const { t } = useI18n()
const props = defineProps({
data: {
type: Object,
default: null,
required: true,
},
})
let averageRating = computed(() => {
return parseInt(props.data.average_rating)
})
</script>

View File

@ -0,0 +1,49 @@
<template>
<BaseContentPlaceholders>
<div
class="
shadow-md
border-2 border-gray-200 border-opacity-60
rounded-lg
cursor-pointer
overflow-hidden
h-100
"
>
<BaseContentPlaceholdersBox class="h-48 lg:h-64 md:h-48 w-full" rounded />
<div class="px-6 py-5 flex flex-col bg-gray-50 flex-1 justify-between">
<BaseContentPlaceholdersText class="w-32 h-8" :lines="1" rounded />
<div class="flex items-center mt-2">
<BaseContentPlaceholdersBox
class="h-10 w-10 rounded-full sm:inline-block mr-2"
/>
<div>
<BaseContentPlaceholdersText
class="w-32 h-8 ml-2"
:lines="1"
rounded
/>
</div>
</div>
<BaseContentPlaceholdersText
class="pt-4 w-full h-16"
:lines="1"
rounded
/>
<div
class="
flex
justify-between
mt-4
flex-col
space-y-2
sm:space-y-0 sm:flex-row
"
>
<BaseContentPlaceholdersText class="w-32 h-8" :lines="1" rounded />
<BaseContentPlaceholdersText class="w-32 h-8" :lines="1" rounded />
</div>
</div>
</div>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,104 @@
<template>
<BaseContentPlaceholders rounded>
<BasePage class="bg-white">
<!-- Breadcrumb-->
<BaseContentPlaceholdersText class="mt-4 h-8 w-40" :lines="1"/>
<BaseContentPlaceholdersText class="mt-4 h-8 w-56 mb-4" :lines="1"/>
<!-- Product -->
<div class="lg:grid lg:grid-rows-1 lg:grid-cols-7 lg:gap-x-8 lg:gap-y-10 xl:gap-x-16 mt-6">
<!-- Product image -->
<div class="lg:row-end-1 lg:col-span-4">
<BaseContentPlaceholdersBox class="h-96 sm:w-full" rounded />
</div>
<!-- Product details -->
<div
class="
max-w-2xl
mx-auto
mt-10
lg:max-w-none lg:mt-0 lg:row-end-2 lg:row-span-2 lg:col-span-3
w-full
"
>
<!-- Average Rating -->
<div>
<h3 class="sr-only">Reviews</h3>
<BaseContentPlaceholdersText class="w-32 h-8" :lines="1" />
<p class="sr-only">4 out of 5 stars</p>
</div>
<!-- Module Name and Version -->
<div class="flex flex-col-reverse">
<div class="mt-4">
<BaseContentPlaceholdersText
class="w-48 xl:w-80 h-12"
:lines="1"
/>
<BaseContentPlaceholdersText
class="w-64 xl:w-80 h-8 mt-2"
:lines="1"
/>
</div>
</div>
<!-- Module Description -->
<div>
<BaseContentPlaceholdersText
class="w-full h-24 my-10"
:lines="1"
/>
</div>
<!-- Module Pricing -->
<div>
<BaseContentPlaceholdersText
class="w-full h-24 mt-6 mb-6"
:lines="1"
/>
</div>
<!-- Button Section -->
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<BaseContentPlaceholdersText class="w-full h-14" :lines="1" />
</div>
<div class="mt-10"></div>
<!-- HightLight -->
<div class="border-t border-gray-200 mt-10 pt-10">
<div>
<BaseContentPlaceholdersText class="w-24 h-6" :lines="1" />
<BaseContentPlaceholdersText
class="mt-4 w-full h-20"
:lines="1"
/>
</div>
</div>
<!-- Social Share -->
<div class="border-t border-gray-200 mt-10 pt-10">
<BaseContentPlaceholdersText class="h-6 w-24" :lines="1" />
<BaseContentPlaceholdersText class="h-10 w-32 mt-4" :lines="1" />
</div>
</div>
<div
class="
w-full
max-w-2xl
mx-auto
mt-16
lg:max-w-none lg:mt-0 lg:col-span-4
"
>
<BaseContentPlaceholdersBox class="h-96 sm:w-full" rounded />
</div>
</div>
</BasePage>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,63 @@
<template>
<router-link class="relative group" :to="`/admin/modules/${data.slug}`">
<div class="relative group">
<div class="aspect-w-4 aspect-h-3 rounded-lg overflow-hidden bg-gray-100">
<img :src="data.cover" class="object-center object-cover" />
<div
class="flex items-end opacity-0 p-4 group-hover:opacity-100"
aria-hidden="true"
>
<div
class="
w-full
bg-white bg-opacity-75
backdrop-filter backdrop-blur
py-2
px-4
rounded-md
text-sm
font-medium
text-primary-500 text-center
"
>
{{ $t('modules.view_module') }}
</div>
</div>
</div>
<div
class="
mt-4
flex
items-center
justify-between
text-base
font-medium
text-gray-900
space-x-8
cursor-pointer
"
>
<h3 class="text-primary-500 font-bold">
<span aria-hidden="true" class="absolute inset-0"></span>
{{ data.name }}
</h3>
<p class="text-primary-500 font-bold">
$ {{ data.monthly_price / 100 }}
</p>
</div>
</div>
</router-link>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
data: {
type: Object,
default: null,
required: true,
},
})
</script>

View File

@ -0,0 +1,527 @@
<template>
<PaymentModeModal />
<BasePage class="relative payment-create">
<form action="" @submit.prevent="submitPaymentData">
<BasePageHeader :title="pageTitle" class="mb-5">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$tc('payments.payment', 2)"
to="/admin/payments"
/>
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="hidden sm:flex"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('payments.update_payment')
: $t('payments.save_payment')
}}
</BaseButton>
</template>
</BasePageHeader>
<BaseCard>
<BaseInputGrid>
<BaseInputGroup
:label="$t('payments.date')"
:content-loading="isLoadingContent"
required
:error="
v$.currentPayment.payment_date.$error &&
v$.currentPayment.payment_date.$errors[0].$message
"
>
<BaseDatePicker
v-model="paymentStore.currentPayment.payment_date"
:content-loading="isLoadingContent"
:calendar-button="true"
calendar-button-icon="calendar"
:invalid="v$.currentPayment.payment_date.$error"
@update:modelValue="v$.currentPayment.payment_date.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('payments.payment_number')"
:content-loading="isLoadingContent"
required
>
<BaseInput
v-model="paymentStore.currentPayment.payment_number"
:content-loading="isLoadingContent"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('payments.customer')"
:error="
v$.currentPayment.customer_id.$error &&
v$.currentPayment.customer_id.$errors[0].$message
"
:content-loading="isLoadingContent"
required
>
<BaseCustomerSelectInput
v-model="paymentStore.currentPayment.customer_id"
:content-loading="isLoadingContent"
:invalid="v$.currentPayment.customer_id.$error"
:placeholder="$t('customers.select_a_customer')"
:fetch-all="isEdit"
show-action
@update:modelValue="
selectNewCustomer(paymentStore.currentPayment.customer_id)
"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isLoadingContent"
:label="$t('payments.invoice')"
:help-text="
selectedInvoice
? `Due Amount: ${
paymentStore.currentPayment.maxPayableAmount / 100
}`
: ''
"
>
<BaseMultiselect
v-model="paymentStore.currentPayment.invoice_id"
:content-loading="isLoadingContent"
value-prop="id"
track-by="invoice_number"
label="invoice_number"
:options="invoiceList"
:loading="isLoadingInvoices"
:placeholder="$t('invoices.select_invoice')"
@select="onSelectInvoice"
>
<template #singlelabel="{ value }">
<div class="absolute left-3.5">
{{ value.invoice_number }} ({{
utils.formatMoney(value.total, value.customer.currency)
}})
</div>
</template>
<template #option="{ option }">
{{ option.invoice_number }} ({{
utils.formatMoney(option.total, option.customer.currency)
}})
</template>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:label="$t('payments.amount')"
:content-loading="isLoadingContent"
:error="
v$.currentPayment.amount.$error &&
v$.currentPayment.amount.$errors[0].$message
"
required
>
<div class="relative w-full">
<BaseMoney
:key="paymentStore.currentPayment.currency"
v-model="amount"
:currency="paymentStore.currentPayment.currency"
:content-loading="isLoadingContent"
:invalid="v$.currentPayment.amount.$error"
@update:modelValue="v$.currentPayment.amount.$touch()"
/>
</div>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isLoadingContent"
:label="$t('payments.payment_mode')"
>
<BaseMultiselect
v-model="paymentStore.currentPayment.payment_method_id"
:content-loading="isLoadingContent"
label="name"
value-prop="id"
track-by="name"
:options="paymentStore.paymentModes"
:placeholder="$t('payments.select_payment_mode')"
searchable
>
<template #action>
<BaseSelectAction @click="addPaymentMode">
<BaseIcon
name="PlusIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('settings.payment_modes.add_payment_mode') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
</BaseInputGroup>
<ExchangeRateConverter
:store="paymentStore"
store-prop="currentPayment"
:v="v$.currentPayment"
:is-loading="isLoadingContent"
:is-edit="isEdit"
:customer-currency="paymentStore.currentPayment.currency_id"
/>
</BaseInputGrid>
<!-- Payment Custom Fields -->
<PaymentCustomFields
type="Payment"
:is-edit="isEdit"
:is-loading="isLoadingContent"
:store="paymentStore"
store-prop="currentPayment"
:custom-field-scope="paymentValidationScope"
class="mt-6"
/>
<!-- Payment Note field -->
<div class="relative mt-6">
<div
class="
z-20
float-right
text-sm
font-semibold
leading-5
text-primary-400
"
>
<SelectNotePopup type="Payment" @select="onSelectNote" />
</div>
<label class="mb-4 text-sm font-medium text-gray-800">
{{ $t('estimates.notes') }}
</label>
<BaseCustomInput
v-model="paymentStore.currentPayment.notes"
:content-loading="isLoadingContent"
:fields="PaymentFields"
class="mt-1"
/>
</div>
<BaseButton
:loading="isSaving"
:content-loading="isLoadingContent"
variant="primary"
type="submit"
class="flex justify-center w-full mt-4 sm:hidden md:hidden"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit ? $t('payments.update_payment') : $t('payments.save_payment')
}}
</BaseButton>
</BaseCard>
</form>
</BasePage>
</template>
<script setup>
import ExchangeRateConverter from '@/scripts/admin/components/estimate-invoice-common/ExchangeRateConverter.vue'
import {
ref,
reactive,
computed,
inject,
watchEffect,
onBeforeUnmount,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
numeric,
helpers,
between,
requiredIf,
decimal,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { usePaymentStore } from '@/scripts/admin/stores/payment'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useModalStore } from '@/scripts/stores/modal'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import SelectNotePopup from '@/scripts/admin/components/SelectNotePopup.vue'
import PaymentCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import PaymentModeModal from '@/scripts/admin/components/modal-components/PaymentModeModal.vue'
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const notificationStore = useNotificationStore()
const customerStore = useCustomerStore()
const customFieldStore = useCustomFieldStore()
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const invoiceStore = useInvoiceStore()
const globalStore = useGlobalStore()
const utils = inject('utils')
const { t } = useI18n()
let isSaving = ref(false)
let isLoadingInvoices = ref(false)
let invoiceList = ref([])
const selectedInvoice = ref(null)
const paymentValidationScope = 'newEstimate'
const PaymentFields = reactive([
'customer',
'company',
'customerCustom',
'payment',
'paymentCustom',
])
const amount = computed({
get: () => paymentStore.currentPayment.amount / 100,
set: (value) => {
paymentStore.currentPayment.amount = Math.round(value * 100)
},
})
const isLoadingContent = computed(() => paymentStore.isFetchingInitialData)
const isEdit = computed(() => route.name === 'payments.edit')
const pageTitle = computed(() => {
if (isEdit.value) {
return t('payments.edit_payment')
}
return t('payments.new_payment')
})
const rules = computed(() => {
return {
currentPayment: {
customer_id: {
required: helpers.withMessage(t('validation.required'), required),
},
payment_date: {
required: helpers.withMessage(t('validation.required'), required),
},
amount: {
required: helpers.withMessage(t('validation.required'), required),
between: helpers.withMessage(
t('validation.payment_greater_than_due_amount'),
between(0, paymentStore.currentPayment.maxPayableAmount)
),
},
exchange_rate: {
required: requiredIf(function () {
helpers.withMessage(t('validation.required'), required)
return paymentStore.showExchangeRate
}),
decimal: helpers.withMessage(
t('validation.valid_exchange_rate'),
decimal
),
},
},
}
})
const v$ = useVuelidate(rules, paymentStore, {
$scope: paymentValidationScope,
})
watchEffect(() => {
// fetch customer and its invoices
paymentStore.currentPayment.customer_id
? onCustomerChange(paymentStore.currentPayment.customer_id)
: ''
if (route.query.customer) {
paymentStore.currentPayment.customer_id = route.query.customer
}
})
// Reset State on Create
paymentStore.resetCurrentPayment()
if (route.query.customer) {
paymentStore.currentPayment.customer_id = route.query.customer
}
paymentStore.fetchPaymentInitialData(isEdit.value)
if (route.params.id && !isEdit.value) {
setInvoiceFromUrl()
}
async function addPaymentMode() {
modalStore.openModal({
title: t('settings.payment_modes.add_payment_mode'),
componentName: 'PaymentModeModal',
})
}
function onSelectNote(data) {
paymentStore.currentPayment.notes = '' + data.notes
}
async function setInvoiceFromUrl() {
let res = await invoiceStore.fetchInvoice(route?.params?.id)
paymentStore.currentPayment.customer_id = res.data.data.customer.id
paymentStore.currentPayment.invoice_id = res.data.data.id
}
async function onSelectInvoice(id) {
if (id) {
selectedInvoice.value = invoiceList.value.find((inv) => inv.id === id)
amount.value = selectedInvoice.value.due_amount / 100
paymentStore.currentPayment.maxPayableAmount =
selectedInvoice.value.due_amount
}
}
function onCustomerChange(customer_id) {
if (customer_id) {
let data = {
customer_id: customer_id,
status: 'DUE',
limit: 'all',
}
if (isEdit.value) {
data.status = ''
}
isLoadingInvoices.value = true
Promise.all([
invoiceStore.fetchInvoices(data),
customerStore.fetchCustomer(customer_id),
])
.then(async ([res1, res2]) => {
if (res1) {
invoiceList.value = [...res1.data.data]
}
if (res2 && res2.data) {
paymentStore.currentPayment.selectedCustomer = res2.data.data
paymentStore.currentPayment.customer = res2.data.data
paymentStore.currentPayment.currency = res2.data.data.currency
}
if (paymentStore.currentPayment.invoice_id) {
selectedInvoice.value = invoiceList.value.find(
(inv) => inv.id === paymentStore.currentPayment.invoice_id
)
paymentStore.currentPayment.maxPayableAmount =
selectedInvoice.value.due_amount +
paymentStore.currentPayment.amount
if (amount.value === 0) {
amount.value = selectedInvoice.value.due_amount / 100
}
}
if (isEdit.value) {
// remove all invoices that are paid except currently selected invoice
invoiceList.value = invoiceList.value.filter((v) => {
return (
v.due_amount > 0 || v.id == paymentStore.currentPayment.invoice_id
)
})
}
isLoadingInvoices.value = false
})
.catch((error) => {
isLoadingInvoices.value = false
console.error(error, 'error')
})
}
}
onBeforeUnmount(() => {
paymentStore.resetCurrentPayment()
invoiceList.value = []
})
async function submitPaymentData() {
v$.value.$touch()
if (v$.value.$invalid) {
return false
}
isSaving.value = true
let data = {
...paymentStore.currentPayment,
}
let response = null
try {
const action = isEdit.value
? paymentStore.updatePayment
: paymentStore.addPayment
response = await action(data)
router.push(`/admin/payments/${response.data.data.id}/view`)
} catch (err) {
isSaving.value = false
}
}
function selectNewCustomer(id) {
let params = {
userId: id,
}
if (route.params.id) params.model_id = route.params.id
paymentStore.currentPayment.invoice_id = selectedInvoice.value = null
paymentStore.currentPayment.amount = 0
invoiceList.value = []
paymentStore.getNextNumber(params, true)
}
</script>

View File

@ -0,0 +1,380 @@
<template>
<BasePage class="payments">
<SendPaymentModal />
<BasePageHeader :title="$t('payments.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$tc('payments.payment', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="paymentStore.paymentTotalCount"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
:class="slotProps.class"
name="FilterIcon"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_PAYMENT)"
variant="primary"
class="ml-4"
@click="$router.push('/admin/payments/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('payments.add_payment') }}
</BaseButton>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup :label="$t('payments.customer')">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('payments.payment_number')">
<BaseInput v-model="filters.payment_number">
<template #left="slotProps">
<BaseIcon name="HashtagIcon" :class="slotProps.class" />
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup :label="$t('payments.payment_mode')">
<BaseMultiselect
v-model="filters.payment_mode"
value-prop="id"
track-by="name"
:filter-results="false"
label="name"
resolve-on-load
:delay="500"
searchable
:options="searchPayment"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('payments.no_payments')"
:description="$t('payments.list_of_payments')"
>
<CapsuleIcon class="mt-5 mb-4" />
<template
v-if="userStore.hasAbilities(abilities.CREATE_PAYMENT)"
#actions
>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/payments/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('payments.add_new_payment') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<!-- Multiple Select Actions -->
<div class="relative flex items-center justify-end h-5">
<BaseDropdown v-if="paymentStore.selectedPayments.length">
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultiplePayments">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableComponent"
:data="fetchData"
:columns="paymentColumns"
:placeholder-count="paymentStore.paymentTotalCount >= 20 ? 10 : 5"
class="mt-3"
>
<!-- Select All Checkbox -->
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="selectAllFieldStatus"
variant="primary"
@change="paymentStore.selectAllPayments"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-payment_date="{ row }">
{{ row.data.formatted_payment_date }}
</template>
<template #cell-payment_number="{ row }">
<router-link
:to="{ path: `payments/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.payment_number }}
</router-link>
</template>
<template #cell-name="{ row }">
<BaseText :text="row.data.customer.name" :length="30" tag="span" />
</template>
<template #cell-payment_mode="{ row }">
<span>
{{ row.data.payment_method ? row.data.payment_method.name : '-' }}
</span>
</template>
<template #cell-invoice_number="{ row }">
<span>
{{
row?.data?.invoice?.invoice_number
? row?.data?.invoice?.invoice_number
: '-'
}}
</span>
</template>
<template #cell-amount="{ row }">
<BaseFormatMoney
:amount="row.data.amount"
:currency="row.data.customer.currency"
/>
</template>
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<PaymentDropdown :row="row.data" :table="tableComponent" />
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { debouncedWatch } from '@vueuse/core'
import { ref, reactive, computed, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogStore } from '@/scripts/stores/dialog'
import { usePaymentStore } from '@/scripts/admin/stores/payment'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import CapsuleIcon from '@/scripts/components/icons/empty/CapsuleIcon.vue'
import PaymentDropdown from '@/scripts/admin/components/dropdowns/PaymentIndexDropdown.vue'
import SendPaymentModal from '@/scripts/admin/components/modal-components/SendPaymentModal.vue'
const { t } = useI18n()
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
let tableComponent = ref(null)
const filters = reactive({
customer: '',
payment_mode: '',
payment_number: '',
})
const paymentStore = usePaymentStore()
const companyStore = useCompanyStore()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const showEmptyScreen = computed(() => {
return !paymentStore.paymentTotalCount && !isFetchingInitialData.value
})
const paymentColumns = computed(() => {
return [
{
key: 'status',
sortable: false,
thClass: 'extra w-10',
tdClass: 'text-left text-sm font-medium extra',
},
{
key: 'payment_date',
label: t('payments.date'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'payment_number', label: t('payments.payment_number') },
{ key: 'name', label: t('payments.customer') },
{ key: 'payment_mode', label: t('payments.payment_mode') },
{ key: 'invoice_number', label: t('invoices.invoice_number') },
{ key: 'amount', label: t('payments.amount') },
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const selectField = computed({
get: () => paymentStore.selectedPayments,
set: (value) => {
return paymentStore.selectPayment(value)
},
})
const selectAllFieldStatus = computed({
get: () => paymentStore.selectAllField,
set: (value) => {
return paymentStore.setSelectAllState(value)
},
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (paymentStore.selectAllField) {
paymentStore.selectAllPayments()
}
})
paymentStore.fetchPaymentModes({ limit: 'all' })
async function searchPayment(search) {
let res = await paymentStore.fetchPaymentModes({ search })
return res.data.data
}
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_PAYMENT,
abilities.EDIT_PAYMENT,
abilities.VIEW_PAYMENT,
abilities.SEND_PAYMENT,
])
}
async function fetchData({ page, filter, sort }) {
let data = {
customer_id: filters.customer_id,
payment_method_id:
filters.payment_mode !== null ? filters.payment_mode : '',
payment_number: filters.payment_number,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await paymentStore.fetchPayments(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable() {
tableComponent.value && tableComponent.value.refresh()
}
function setFilters() {
refreshTable()
}
function clearFilter() {
filters.customer_id = ''
filters.payment_mode = ''
filters.payment_number = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function removeMultiplePayments() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('payments.confirm_delete', 2),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
paymentStore.deleteMultiplePayments().then((response) => {
if (response.data.success) {
refreshTable()
}
})
}
})
}
</script>

View File

@ -0,0 +1,436 @@
<template>
<SendPaymentModal />
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<BaseButton
v-if="userStore.hasAbilities(abilities.SEND_PAYMENT)"
:disabled="isSendingEmail"
:content-loading="isFetching"
variant="primary"
@click="onPaymentSend"
>
{{ $t('payments.send_payment_receipt') }}
</BaseButton>
<PaymentDropdown
:content-loading="isFetching"
class="ml-3"
:row="payment"
/>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="
fixed
top-0
left-0
hidden
h-full
pt-16
pb-4
ml-56
bg-white
xl:ml-64
w-88
xl:block
"
>
<div
class="
flex
items-center
justify-between
px-4
pt-8
pb-6
border border-gray-200 border-solid
"
>
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
type="text"
@input="onSearch"
>
<BaseIcon name="SearchIcon" class="h-5" />
</BaseInput>
<div class="flex ml-3" role="group" aria-label="First group">
<BaseDropdown
position="bottom-start"
width-class="w-50"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FilterIcon" />
</BaseButton>
</template>
<div
class="
px-4
py-1
pb-2
mb-2
text-sm
border-b border-gray-200 border-solid
"
>
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_invoice_number"
v-model="searchData.orderByField"
:label="$t('invoices.title')"
size="sm"
name="filter"
value="invoice_number"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
v-model="searchData.orderByField"
:label="$t('payments.date')"
size="sm"
name="filter"
value="payment_date"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_payment_number"
v-model="searchData.orderByField"
:label="$t('payments.payment_number')"
size="sm"
name="filter"
value="payment_number"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" />
<BaseIcon v-else name="SortDescendingIcon" />
</BaseButton>
</div>
</div>
<div
v-if="paymentStore && paymentStore.payments"
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
"
>
<div v-for="(payment, index) in paymentStore.payments" :key="index">
<router-link
v-if="payment && !isLoading"
:id="'payment-' + payment.id"
:to="`/admin/payments/${payment.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-gray-100 border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid':
hasActiveUrl(payment.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<BaseText
:text="payment?.customer?.name"
:length="30"
class="
pr-2
mb-2
text-sm
not-italic
font-normal
leading-5
text-black
capitalize
truncate
"
/>
<div
class="
mb-1
text-xs
not-italic
font-medium
leading-5
text-gray-500
capitalize
"
>
{{ payment?.payment_number }}
</div>
<div
class="
mb-1
text-xs
not-italic
font-medium
leading-5
text-gray-500
capitalize
"
>
{{ payment?.invoice_number }}
</div>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
class="
block
mb-2
text-xl
not-italic
font-semibold
leading-8
text-right text-gray-900
"
:amount="payment?.amount"
:currency="payment.customer?.currency"
/>
<div class="text-sm text-right text-gray-500 non-italic">
{{ payment.formatted_payment_date }}
</div>
</div>
</router-link>
</div>
<div class="flex justify-center p-4 items-center">
<LoadingIcon
v-if="isLoading"
class="h-6 m-1 animate-spin text-primary-400"
/>
</div>
<p
v-if="!paymentStore?.payments?.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('payments.no_matching_payments') }}
</p>
</div>
</div>
<!-- pdf -->
<div
class="flex flex-col min-h-0 mt-8 overflow-hidden"
style="height: 75vh"
>
<iframe
v-if="shareableLink"
:src="shareableLink"
class="flex-1 border border-gray-400 border-solid rounded-md"
/>
</div>
</BasePage>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { debounce } from 'lodash'
import { ref, reactive, computed, inject, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDialogStore } from '@/scripts/stores/dialog'
import { usePaymentStore } from '@/scripts/admin/stores/payment'
import { useModalStore } from '@/scripts/stores/modal'
import PaymentDropdown from '@/scripts/admin/components/dropdowns/PaymentIndexDropdown.vue'
import moment from 'moment'
import { useUserStore } from '@/scripts/admin/stores/user'
import SendPaymentModal from '@/scripts/admin/components/modal-components/SendPaymentModal.vue'
import abilities from '@/scripts/admin/stub/abilities'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
let id = ref(null)
let count = ref(null)
let payment = reactive({})
let currency = ref(null)
let searchData = reactive({
orderBy: null,
orderByField: null,
searchText: null,
})
let isSearching = ref(false)
let isSendingEmail = ref(false)
let isMarkingAsSent = ref(false)
let isLoading = ref(false)
let isFetching = ref(false)
const $utils = inject('utils')
const paymentStore = usePaymentStore()
const modalStore = useModalStore()
const userStore = useUserStore()
const pageTitle = computed(() => {
return payment.payment_number || ''
})
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() =>
getOrderBy.value ? t('general.ascending') : t('general.descending')
)
const shareableLink = computed(() => {
return payment.unique_hash ? `/payments/pdf/${payment.unique_hash}` : false
})
const paymentDate = computed(() => {
return moment(paymentStore?.selectedPayment?.payment_date).format(
'YYYY/MM/DD'
)
})
watch(route, () => {
loadPayment()
})
loadPayments()
loadPayment()
onSearch = debounce(onSearch, 500)
function hasActiveUrl(id) {
return route.params.id == id
}
function hasAbilities() {
return userStore.hasAbilities([
abilities.DELETE_PAYMENT,
abilities.EDIT_PAYMENT,
abilities.VIEW_PAYMENT,
])
}
const dialogStore = useDialogStore()
async function loadPayments() {
isLoading.value = true
await paymentStore.fetchPayments({ limit: 'all' })
isLoading.value = false
setTimeout(() => {
scrollToPayment()
}, 500)
}
async function loadPayment() {
if (!route.params.id) return
isFetching.value = true
let response = await paymentStore.fetchPayment(route.params.id)
if (response.data) {
isFetching.value = false
Object.assign(payment, response.data.data)
}
}
function scrollToPayment() {
const el = document.getElementById(`payment-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch() {
let data = {}
if (
searchData.searchText !== '' &&
searchData.searchText !== null &&
searchData.searchText !== undefined
) {
data.search = searchData.searchText
}
if (searchData.orderBy !== null && searchData.orderBy !== undefined) {
data.orderBy = searchData.orderBy
}
if (
searchData.orderByField !== null &&
searchData.orderByField !== undefined
) {
data.orderByField = searchData.orderByField
}
isSearching.value = true
try {
let response = await paymentStore.searchPayment(data)
isSearching.value = false
if (response.data.data) {
paymentStore.payments = response.data.data
}
} catch (error) {
isSearching.value = false
}
}
function sortData() {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
onSearch()
return true
}
searchData.orderBy = 'asc'
onSearch()
return true
}
async function onPaymentSend() {
modalStore.openModal({
title: t('payments.send_payment'),
componentName: 'SendPaymentModal',
id: payment.id,
data: payment,
variant: 'lg',
})
}
</script>

View File

@ -0,0 +1,496 @@
<template>
<BasePage>
<SendInvoiceModal />
<BasePageHeader :title="$t('recurring_invoices.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$tc('recurring_invoices.invoice', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="recurringInvoiceStore.totalRecurringInvoices"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FilterIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_RECURRING_INVOICE)"
to="recurring-invoices/create"
>
<BaseButton variant="primary" class="ml-4">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('recurring_invoices.new_invoice') }}
</BaseButton>
</router-link>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$tc('customers.customer', 1)">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('recurring_invoices.status')">
<BaseMultiselect
v-model="filters.status"
:options="statusList"
searchable
:placeholder="$t('general.select_a_status')"
@update:modelValue="setActiveTab"
@remove="clearStatusSearch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('general.to')">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('recurring_invoices.no_invoices')"
:description="$t('recurring_invoices.list_of_invoices')"
>
<MoonwalkerIcon class="mt-5 mb-4" />
<template
v-if="userStore.hasAbilities(abilities.CREATE_RECURRING_INVOICE)"
#actions
>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/recurring-invoices/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('recurring_invoices.add_new_invoice') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<div
class="
relative
flex
items-center
justify-between
h-10
mt-5
list-none
border-b-2 border-gray-200 border-solid
"
>
<!-- Tabs -->
<BaseTabGroup
class="-mb-5"
:default-index="currentStatusIndex"
@change="setStatusFilter"
>
<BaseTab :title="$t('recurring_invoices.active')" filter="ACTIVE" />
<BaseTab :title="$t('recurring_invoices.on_hold')" filter="ON_HOLD" />
<BaseTab :title="$t('recurring_invoices.all')" filter="ALL" />
</BaseTabGroup>
<BaseDropdown
v-if="recurringInvoiceStore.selectedRecurringInvoices.length"
class="absolute float-right"
>
<template #activator>
<span
class="
flex
text-sm
font-medium
cursor-pointer
select-none
text-primary-400
"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" class="h-5" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleRecurringInvoices()">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="table"
:data="fetchData"
:columns="invoiceColumns"
:placeholder-count="
recurringInvoiceStore.totalRecurringInvoices >= 20 ? 10 : 5
"
class="mt-10"
>
<!-- Select All Checkbox -->
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="recurringInvoiceStore.selectAllField"
variant="primary"
@change="recurringInvoiceStore.selectAllRecurringInvoices"
/>
</div>
</template>
<template #cell-checkbox="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
/>
</div>
</template>
<!-- Starts at -->
<template #cell-starts_at="{ row }">
{{ row.data.formatted_starts_at }}
</template>
<!-- Customer -->
<template #cell-customer="{ row }">
<router-link :to="{ path: `recurring-invoices/${row.data.id}/view` }">
<BaseText
:text="row.data.customer.name"
:length="30"
tag="span"
class="font-medium text-primary-500 flex flex-col"
/>
<BaseText
:text="
row.data.customer.contact_name
? row.data.customer.contact_name
: ''
"
:length="30"
tag="span"
class="text-xs text-gray-400"
/>
</router-link>
</template>
<!-- Frequency -->
<template #cell-frequency="{ row }">
{{ getFrequencyLabel(row.data.frequency) }}
</template>
<!-- Status -->
<template #cell-status="{ row }">
<BaseRecurringInvoiceStatusBadge
:status="row.data.status"
class="px-3 py-1"
>
{{ row.data.status }}
</BaseRecurringInvoiceStatusBadge>
</template>
<!-- Amount -->
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<!-- Actions -->
<template v-if="canViewActions" #cell-actions="{ row }">
<RecurringInvoiceIndexDropdown :row="row.data" :table="table" />
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import { useUserStore } from '@/scripts/admin/stores/user'
import { debouncedWatch } from '@vueuse/core'
import SendInvoiceModal from '@/scripts/admin/components/modal-components/SendInvoiceModal.vue'
import RecurringInvoiceIndexDropdown from '@/scripts/admin/components/dropdowns/RecurringInvoiceIndexDropdown.vue'
import MoonwalkerIcon from '@/scripts/components/icons/empty/MoonwalkerIcon.vue'
import abilities from '@/scripts/admin/stub/abilities'
const recurringInvoiceStore = useRecurringInvoiceStore()
const customerStore = useCustomerStore()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const userStore = useUserStore()
const table = ref(null)
const { t } = useI18n()
const showFilters = ref(false)
const statusList = ref(['ACTIVE', 'ON_HOLD', 'ALL'])
const isRequestOngoing = ref(true)
const activeTab = ref('recurring-invoices.all')
const router = useRouter()
let filters = reactive({
customer_id: '',
status: 'ACTIVE',
from_date: '',
to_date: '',
})
const showEmptyScreen = computed(
() => !recurringInvoiceStore.totalRecurringInvoices && !isRequestOngoing.value
)
const selectField = computed({
get: () => recurringInvoiceStore.selectedRecurringInvoices,
set: (value) => {
return recurringInvoiceStore.selectRecurringInvoice(value)
},
})
const invoiceColumns = computed(() => {
return [
{
key: 'checkbox',
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'starts_at',
label: t('recurring_invoices.starts_at'),
thClass: 'extra',
tdClass: 'font-medium',
},
{ key: 'customer', label: t('invoices.customer') },
{ key: 'frequency', label: t('recurring_invoices.frequency.title') },
{ key: 'status', label: t('invoices.status') },
{ key: 'total', label: t('invoices.total') },
{
key: 'actions',
label: t('recurring_invoices.action'),
tdClass: 'text-right text-sm font-medium',
thClass: 'text-right',
sortable: false,
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (recurringInvoiceStore.selectAllField) {
recurringInvoiceStore.selectAllRecurringInvoices()
}
})
const currentStatusIndex = computed(() => {
return statusList.value.findIndex((status) => status === filters.status)
})
function canViewActions() {
return userStore.hasAbilities([
abilities.DELETE_RECURRING_INVOICE,
abilities.EDIT_RECURRING_INVOICE,
abilities.VIEW_RECURRING_INVOICE,
])
}
function getFrequencyLabel(frequencyFormat) {
const frequencyObj = recurringInvoiceStore.frequencies.find((frequency) => {
return frequency.value === frequencyFormat
})
return frequencyObj ? frequencyObj.label : `CUSTOM: ${frequencyFormat}`
}
function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
customer_id: filters.customer_id,
status: filters.status,
from_date: filters.from_date,
to_date: filters.to_date,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isRequestOngoing.value = true
let response = await recurringInvoiceStore.fetchRecurringInvoices(data)
isRequestOngoing.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function setStatusFilter(val) {
if (activeTab.value == val.title) {
return true
}
activeTab.value = val.title
switch (val.title) {
case t('recurring_invoices.active'):
filters.status = 'ACTIVE'
break
case t('recurring_invoices.on_hold'):
filters.status = 'ON_HOLD'
break
case t('recurring_invoices.all'):
filters.status = 'ALL'
break
}
}
function setFilters() {
recurringInvoiceStore.$patch((state) => {
state.selectedRecurringInvoices = []
state.selectAllField = false
})
refreshTable()
}
function clearFilter() {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.invoice_number = ''
activeTab.value = t('general.all')
}
async function removeMultipleRecurringInvoices(id = null) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('invoices.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
await recurringInvoiceStore
.deleteMultipleRecurringInvoices(id)
.then((res) => {
if (res.data.success) {
refreshTable()
recurringInvoiceStore.$patch((state) => {
state.selectedRecurringInvoices = []
state.selectAllField = false
})
notificationStore.showNotification({
type: 'success',
message: t('recurring_invoices.deleted_message', 2),
})
} else if (res.data.error) {
notificationStore.showNotification({
type: 'error',
message: res.data.message,
})
}
})
}
})
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function clearStatusSearch(removedOption, id) {
filters.status = ''
refreshTable()
}
function setActiveTab(val) {
switch (val) {
case 'ACTIVE':
activeTab.value = t('recurring_invoices.active')
break
case 'ON_HOLD':
activeTab.value = t('recurring_invoices.on_hold')
break
case 'ALL':
activeTab.value = t('recurring_invoices.all')
break
}
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<RecurringInvoiceIndexDropdown
v-if="hasAtleastOneAbility()"
:row="recurringInvoiceStore.newRecurringInvoice"
/>
</template>
</BasePageHeader>
<RecurringInvoiceViewSidebar />
<RecurringInvoiceInfo />
</BasePage>
</template>
<script setup>
import { ref, computed, inject } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import abilities from '@/scripts/admin/stub/abilities'
import RecurringInvoiceViewSidebar from '@/scripts/admin/views/recurring-invoices/partials/RecurringInvoiceViewSidebar.vue'
import RecurringInvoiceInfo from '@/scripts/admin/views/recurring-invoices/partials/RecurringInvoiceInfo.vue'
import RecurringInvoiceIndexDropdown from '@/scripts/admin/components/dropdowns/RecurringInvoiceIndexDropdown.vue'
const dialogStore = useDialogStore()
const recurringInvoiceStore = useRecurringInvoiceStore()
const userStore = useUserStore()
const { t } = useI18n()
const router = useRouter()
const pageTitle = computed(() => {
return recurringInvoiceStore.newRecurringInvoice
? recurringInvoiceStore.newRecurringInvoice?.customer?.name
: ''
})
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_RECURRING_INVOICE,
abilities.EDIT_RECURRING_INVOICE,
])
}
function removeRecurringInvoice(id) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('recurring_invoices.confirm_delete', 1),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
size: 'lg',
hideNoButton: false,
})
.then((res) => {
if (res) {
let data = { ids: [id] }
let response = recurringInvoiceStore
.deleteRecurringInvoice(data)
.then((res) => {
if (response) {
router.push('/admin/recurring-invoices')
return true
}
})
}
})
}
</script>

View File

@ -0,0 +1,329 @@
<template>
<SelectTemplateModal />
<ItemModal />
<TaxTypeModal />
<SalesTax
v-if="salesTaxEnabled && !isLoadingContent"
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
:is-edit="isEdit"
:customer="recurringInvoiceStore.newRecurringInvoice.customer"
/>
<BasePage class="relative invoice-create-page">
<form @submit.prevent="submitForm">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$t('recurring_invoices.title', 2)"
to="/admin/recurring-invoices"
/>
<BaseBreadcrumbItem
v-if="$route.name === 'invoices.edit'"
:title="$t('recurring_invoices.edit_invoice')"
to="#"
active
/>
<BaseBreadcrumbItem
v-else
:title="pageTitle"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<router-link
:to="`/invoices/pdf/${recurringInvoiceStore.newRecurringInvoice.unique_hash}`"
>
<BaseButton
v-if="$route.name === 'invoices.edit'"
target="_blank"
class="mr-3"
variant="primary-outline"
type="button"
>
<span class="flex">
{{ $t('general.view_pdf') }}
</span>
</BaseButton>
</router-link>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{ $t('recurring_invoices.save_invoice') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Select Customer & Basic Fields -->
<div class="grid-cols-12 gap-8 mt-6 mb-8 lg:grid">
<InvoiceBasicFields
:v="v$"
:is-loading="isLoadingContent"
:is-edit="isEdit"
/>
</div>
<BaseScrollPane>
<!-- Invoice Items -->
<CreateItems
:currency="recurringInvoiceStore.newRecurringInvoice.currency"
:is-loading="isLoadingContent"
:item-validation-scope="recurringInvoiceValidationScope"
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
/>
<!-- Invoice Templates -->
<div
class="
block
mt-10
invoice-foot
lg:flex lg:justify-between lg:items-start
"
>
<div class="w-full relative lg:w-1/2">
<!-- Invoice Custom Notes -->
<NoteFields
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
:fields="recurringInvoiceFields"
type="Invoice"
/>
<!-- Invoice Custom Fields -->
<InvoiceCustomFields
type="Invoice"
:is-edit="isEdit"
:is-loading="isLoadingContent"
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
:custom-field-scope="recurringInvoiceValidationScope"
class="mb-6"
/>
<!-- Invoice Template Button-->
<SelectTemplateButton
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
/>
</div>
<!-- Invoice Total Card -->
<CreateTotal
:currency="recurringInvoiceStore.newRecurringInvoice.currency"
:is-loading="isLoadingContent"
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
tax-popup-type="invoice"
/>
</div>
</BaseScrollPane>
</form>
</BasePage>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import CreateItems from '@/scripts/admin/components/estimate-invoice-common/CreateItems.vue'
import CreateTotal from '@/scripts/admin/components/estimate-invoice-common/CreateTotal.vue'
import SelectTemplateButton from '@/scripts/admin/components/estimate-invoice-common/SelectTemplateButton.vue'
import InvoiceBasicFields from './RecurringInvoiceCreateBasicFields.vue'
import InvoiceCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import NoteFields from '@/scripts/admin/components/estimate-invoice-common/CreateNotesField.vue'
import SalesTax from '@/scripts/admin/components/estimate-invoice-common/SalesTax.vue'
import {
required,
maxLength,
numeric,
helpers,
requiredIf,
decimal,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import { useModuleStore } from '@/scripts/admin/stores/module'
import SelectTemplateModal from '@/scripts/admin/components/modal-components/SelectTemplateModal.vue'
import TaxTypeModal from '@/scripts/admin/components/modal-components/TaxTypeModal.vue'
import ItemModal from '@/scripts/admin/components/modal-components/ItemModal.vue'
const recurringInvoiceStore = useRecurringInvoiceStore()
const companyStore = useCompanyStore()
const customFieldStore = useCustomFieldStore()
const moduleStore = useModuleStore()
const recurringInvoiceValidationScope = 'newRecurringInvoice'
const { t } = useI18n()
let isSaving = ref(false)
const recurringInvoiceFields = ref([
'customer',
'company',
'customerCustom',
'invoice',
'invoiceCustom',
])
let route = useRoute()
let router = useRouter()
let isLoadingContent = computed(
() =>
recurringInvoiceStore.isFetchingInvoice ||
recurringInvoiceStore.isFetchingInitialSettings
)
let pageTitle = computed(() =>
isEdit.value
? t('recurring_invoices.edit_invoice')
: t('recurring_invoices.new_invoice')
)
let isEdit = computed(() => route.name === 'recurring-invoices.edit')
const salesTaxEnabled = computed(() => {
return (
companyStore.selectedCompanySettings.sales_tax_us_enabled === 'YES' &&
moduleStore.salesTaxUSEnabled
)
})
const rules = {
starts_at: {
required: helpers.withMessage(t('validation.required'), required),
},
status: {
required: helpers.withMessage(t('validation.required'), required),
},
frequency: {
required: helpers.withMessage(t('validation.required'), required),
},
limit_by: {
required: helpers.withMessage(t('validation.required'), required),
},
limit_date: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(function () {
return recurringInvoiceStore.newRecurringInvoice.limit_by === 'DATE'
})
),
},
limit_count: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(function () {
return recurringInvoiceStore.newRecurringInvoice.limit_by === 'COUNT'
})
),
},
selectedFrequency: {
required: helpers.withMessage(t('validation.required'), required),
},
customer_id: {
required: helpers.withMessage(t('validation.required'), required),
},
exchange_rate: {
required: requiredIf(function () {
helpers.withMessage(t('validation.required'), required)
return recurringInvoiceStore.showExchangeRate
}),
decimal: helpers.withMessage(t('validation.valid_exchange_rate'), decimal),
},
}
const v$ = useVuelidate(
rules,
computed(() => recurringInvoiceStore.newRecurringInvoice),
{ $scope: recurringInvoiceValidationScope }
)
recurringInvoiceStore.resetCurrentRecurringInvoice()
recurringInvoiceStore.fetchRecurringInvoiceInitialSettings(isEdit.value)
customFieldStore.resetCustomFields()
v$.value.$reset
watch(
() => recurringInvoiceStore.newRecurringInvoice.customer,
(newVal) => {
if (newVal && newVal.currency) {
recurringInvoiceStore.newRecurringInvoice.currency = newVal.currency
} else {
recurringInvoiceStore.newRecurringInvoice.currency =
companyStore.selectedCompanyCurrency
}
}
)
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return false
}
isSaving.value = true
let data = {
...recurringInvoiceStore.newRecurringInvoice,
sub_total: recurringInvoiceStore.getSubTotal,
total: recurringInvoiceStore.getTotal,
tax: recurringInvoiceStore.getTotalTax,
}
if (route.params.id) {
recurringInvoiceStore
.updateRecurringInvoice(data)
.then((res) => {
if (res.data.data) {
router.push(`/admin/recurring-invoices/${res.data.data.id}/view`)
}
isSaving.value = false
})
.catch((err) => {
isSaving.value = false
})
} else {
submitCreate(data)
}
}
function submitCreate(data) {
recurringInvoiceStore
.addRecurringInvoice(data)
.then((res) => {
if (res.data.data) {
router.push(`/admin/recurring-invoices/${res.data.data.id}/view`)
}
isSaving.value = false
})
.catch((err) => {
isSaving.value = false
})
}
function checkValid() {
return false
}
</script>

View File

@ -0,0 +1,285 @@
<template>
<div class="col-span-5 pr-0">
<BaseCustomerSelectPopup
v-model="recurringInvoiceStore.newRecurringInvoice.customer"
:valid="v.customer_id"
:content-loading="isLoading"
type="recurring-invoice"
/>
<div class="flex mt-7">
<div class="relative w-20 mt-8">
<BaseSwitch
v-model="recurringInvoiceStore.newRecurringInvoice.send_automatically"
class="absolute -top-4"
/>
</div>
<div class="ml-2">
<p class="p-0 mb-1 leading-snug text-left text-black">
{{ $t('recurring_invoices.send_automatically') }}
</p>
<p
class="p-0 m-0 text-xs leading-tight text-left text-gray-500"
style="max-width: 480px"
>
{{ $t('recurring_invoices.send_automatically_desc') }}
</p>
</div>
</div>
</div>
<div
class="
grid grid-cols-1
col-span-7
gap-4
mt-8
lg:gap-6 lg:mt-0 lg:grid-cols-2
"
>
<BaseInputGroup
:label="$t('recurring_invoices.starts_at')"
:content-loading="isLoading"
required
:error="v.starts_at.$error && v.starts_at.$errors[0].$message"
>
<BaseDatePicker
v-model="recurringInvoiceStore.newRecurringInvoice.starts_at"
:content-loading="isLoading"
:calendar-button="true"
calendar-button-icon="calendar"
:invalid="v.starts_at.$error"
@change="getNextInvoiceDate()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('recurring_invoices.next_invoice_date')"
:content-loading="isLoading"
required
>
<BaseDatePicker
v-model="recurringInvoiceStore.newRecurringInvoice.next_invoice_at"
:content-loading="isLoading"
:calendar-button="true"
:disabled="true"
:loading="isLoadingNextDate"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('recurring_invoices.limit_by')"
:content-loading="isLoading"
class="lg:mt-0"
required
:error="v.limit_by.$error && v.limit_by.$errors[0].$message"
>
<BaseMultiselect
v-model="recurringInvoiceStore.newRecurringInvoice.limit_by"
:content-loading="isLoading"
:options="limits"
label="label"
:invalid="v.limit_by.$error"
value-prop="value"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="hasLimitBy('DATE')"
:label="$t('recurring_invoices.limit_date')"
:content-loading="isLoading"
:required="hasLimitBy('DATE')"
:error="v.limit_date.$error && v.limit_date.$errors[0].$message"
>
<BaseDatePicker
v-model="recurringInvoiceStore.newRecurringInvoice.limit_date"
:content-loading="isLoading"
:invalid="v.limit_date.$error"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="hasLimitBy('COUNT')"
:label="$t('recurring_invoices.count')"
:content-loading="isLoading"
:required="hasLimitBy('COUNT')"
:error="v.limit_count.$error && v.limit_count.$errors[0].$message"
>
<BaseInput
v-model="recurringInvoiceStore.newRecurringInvoice.limit_count"
:content-loading="isLoading"
:invalid="v.limit_count.$error"
type="number"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('recurring_invoices.status')"
required
:content-loading="isLoading"
:error="v.status.$error && v.status.$errors[0].$message"
>
<BaseMultiselect
v-model="recurringInvoiceStore.newRecurringInvoice.status"
:options="getStatusOptions"
:content-loading="isLoading"
:invalid="v.status.$error"
:placeholder="$t('recurring_invoices.select_a_status')"
value-prop="value"
label="value"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('recurring_invoices.frequency.select_frequency')"
required
:content-loading="isLoading"
:error="
v.selectedFrequency.$error && v.selectedFrequency.$errors[0].$message
"
>
<BaseMultiselect
v-model="recurringInvoiceStore.newRecurringInvoice.selectedFrequency"
:content-loading="isLoading"
:options="recurringInvoiceStore.frequencies"
label="label"
:invalid="v.selectedFrequency.$error"
object
@change="getNextInvoiceDate"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isCustomFrequency"
:label="$t('recurring_invoices.frequency.title')"
:content-loading="isLoading"
required
:error="v.frequency.$error && v.frequency.$errors[0].$message"
>
<BaseInput
v-model="recurringInvoiceStore.newRecurringInvoice.frequency"
:content-loading="isLoading"
:disabled="!isCustomFrequency"
:invalid="v.frequency.$error"
:loading="isLoadingNextDate"
@update:modelValue="debounceNextDate"
/>
</BaseInputGroup>
<ExchangeRateConverter
:store="recurringInvoiceStore"
store-prop="newRecurringInvoice"
:v="v"
:is-loading="isLoading"
:is-edit="isEdit"
:customer-currency="recurringInvoiceStore.newRecurringInvoice.currency_id"
/>
</div>
</template>
<script setup>
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useDebounceFn } from '@vueuse/core'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ExchangeRateConverter from '@/scripts/admin/components/estimate-invoice-common/ExchangeRateConverter.vue'
const props = defineProps({
v: {
type: Object,
default: null,
},
isLoading: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
})
const route = useRoute()
const recurringInvoiceStore = useRecurringInvoiceStore()
const globalStore = useGlobalStore()
const isLoadingNextDate = ref(false)
const limits = reactive([
{ label: 'None', value: 'NONE' },
{ label: 'Date', value: 'DATE' },
{ label: 'Count', value: 'COUNT' },
])
const isCustomFrequency = computed(() => {
return (
recurringInvoiceStore.newRecurringInvoice.selectedFrequency &&
recurringInvoiceStore.newRecurringInvoice.selectedFrequency.value ===
'CUSTOM'
)
})
const getStatusOptions = computed(() => {
if (props.isEdit) {
return globalStore.config.recurring_invoice_status.update_status
}
return globalStore.config.recurring_invoice_status.create_status
})
watch(
() => recurringInvoiceStore.newRecurringInvoice.selectedFrequency,
(newValue) => {
if (!recurringInvoiceStore.isFetchingInitialSettings) {
if (newValue && newValue.value !== 'CUSTOM') {
recurringInvoiceStore.newRecurringInvoice.frequency = newValue.value
} else {
recurringInvoiceStore.newRecurringInvoice.frequency = null
}
}
}
)
onMounted(() => {
// on create
if (!route.params.id) {
getNextInvoiceDate()
}
})
function hasLimitBy(LimitBy) {
return recurringInvoiceStore.newRecurringInvoice.limit_by === LimitBy
}
const debounceNextDate = useDebounceFn(() => {
getNextInvoiceDate()
}, 500)
async function getNextInvoiceDate() {
const val = recurringInvoiceStore.newRecurringInvoice.frequency
if (!val) {
return
}
isLoadingNextDate.value = true
let data = {
starts_at: recurringInvoiceStore.newRecurringInvoice.starts_at,
frequency: val,
}
try {
await recurringInvoiceStore.fetchRecurringInvoiceFrequencyDate(data)
} catch (error) {
console.error(error)
isLoadingNextDate.value = false
}
isLoadingNextDate.value = false
}
</script>

View File

@ -0,0 +1,99 @@
<template>
<SendInvoiceModal />
<div class="relative table-container">
<BaseTable
ref="table"
:data="recurringInvoiceStore.newRecurringInvoice.invoices"
:columns="invoiceColumns"
:loading="recurringInvoiceStore.isFetchingViewData"
:placeholder-count="5"
class="mt-5"
>
<!-- Invoice Number -->
<template #cell-invoice_number="{ row }">
<router-link
:to="{ path: `/admin/invoices/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.invoice_number }}
</router-link>
</template>
<!-- Invoice Due amount -->
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.due_amount"
:currency="row.data.currency"
/>
</template>
<!-- Invoice status -->
<template #cell-status="{ row }">
<BaseInvoiceStatusBadge :status="row.data.status" class="px-3 py-1">
{{ row.data.status }}
</BaseInvoiceStatusBadge>
</template>
<!-- Actions -->
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<InvoiceDropdown :row="row.data" :table="table" />
</template>
</BaseTable>
</div>
</template>
<script setup>
import { computed, ref, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import abilities from '@/scripts/admin/stub/abilities'
import InvoiceDropdown from '@/scripts/admin/components/dropdowns/InvoiceIndexDropdown.vue'
const recurringInvoiceStore = useRecurringInvoiceStore()
const table = ref(null)
const baseSelect = ref(null)
const utils = inject('$utils')
const { t } = useI18n()
const currency = ref(null)
const router = useRouter()
const userStore = useUserStore()
const invoiceColumns = computed(() => {
return [
{
key: 'invoice_date',
label: t('invoices.date'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'invoice_number', label: t('invoices.invoice') },
{ key: 'customer.name', label: t('invoices.customer') },
{ key: 'status', label: t('invoices.status') },
{ key: 'total', label: t('invoices.total') },
{
key: 'actions',
label: t('invoices.action'),
tdClass: 'text-right text-sm font-medium',
thClass: 'text-right',
sortable: false,
},
]
})
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_INVOICE,
abilities.EDIT_INVOICE,
abilities.VIEW_INVOICE,
abilities.SEND_INVOICE,
])
}
function refreshTable() {
table.value && table.value.refresh()
}
</script>

View File

@ -0,0 +1,94 @@
<template>
<BaseCard class="mt-10">
<BaseHeading>
{{ $t('customers.basic_info') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
:label="$t('recurring_invoices.starts_at')"
:content-loading="isLoading"
:value="recurringInvoiceStore.newRecurringInvoice?.formatted_starts_at"
/>
<BaseDescriptionListItem
:label="$t('recurring_invoices.next_invoice_date')"
:content-loading="isLoading"
:value="
recurringInvoiceStore.newRecurringInvoice?.formatted_next_invoice_at
"
/>
<BaseDescriptionListItem
v-if="
recurringInvoiceStore.newRecurringInvoice?.limit_date &&
recurringInvoiceStore.newRecurringInvoice?.limit_by !== 'NONE'
"
:label="$t('recurring_invoices.limit_date')"
:content-loading="isLoading"
:value="recurringInvoiceStore.newRecurringInvoice?.limit_date"
/>
<BaseDescriptionListItem
v-if="
recurringInvoiceStore.newRecurringInvoice?.limit_date &&
recurringInvoiceStore.newRecurringInvoice?.limit_by !== 'NONE'
"
:label="$t('recurring_invoices.limit_by')"
:content-loading="isLoading"
:value="recurringInvoiceStore.newRecurringInvoice?.limit_by"
/>
<BaseDescriptionListItem
v-if="recurringInvoiceStore.newRecurringInvoice?.limit_count"
:label="$t('recurring_invoices.limit_count')"
:value="recurringInvoiceStore.newRecurringInvoice?.limit_count"
:content-loading="isLoading"
/>
<BaseDescriptionListItem
v-if="recurringInvoiceStore.newRecurringInvoice?.selectedFrequency"
:label="$t('recurring_invoices.frequency.title')"
:value="
recurringInvoiceStore.newRecurringInvoice?.selectedFrequency?.label
"
:content-loading="isLoading"
/>
</BaseDescriptionList>
<BaseHeading class="mt-8">
{{ $t('invoices.title', 2) }}
</BaseHeading>
<Invoices />
</BaseCard>
</template>
<script setup>
import { ref, computed, watch, reactive, inject } from 'vue'
import { useRoute } from 'vue-router'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import Invoices from './Invoices.vue'
const recurringInvoiceStore = useRecurringInvoiceStore()
const route = useRoute()
let isLoading = computed(() => {
return recurringInvoiceStore.isFetchingViewData
})
watch(
route,
() => {
if (route.params.id && route.name === 'recurring-invoices.view') {
loadRecurringInvoice()
}
},
{ immediate: true }
)
async function loadRecurringInvoice() {
await recurringInvoiceStore.fetchRecurringInvoice(route.params.id)
}
</script>

View File

@ -0,0 +1,322 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, reactive, ref, watch, inject } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { debounce } from 'lodash'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import { useModalStore } from '@/scripts/stores/modal'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useDialogStore } from '@/scripts/stores/dialog'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
const modalStore = useModalStore()
const recurringInvoiceStore = useRecurringInvoiceStore()
const notificationStore = useNotificationStore()
const userStore = useUserStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const id = ref(null)
const count = ref(null)
const currency = ref(null)
const route = useRoute()
const router = useRouter()
const status = ref([
'DRAFT',
'SENT',
'VIEWED',
'EXPIRED',
'ACCEPTED',
'REJECTED',
])
const isSearching = ref(false)
const isLoading = ref(false)
const searchData = reactive({
orderBy: null,
orderByField: null,
searchText: null,
})
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadRecurringInvoices() {
isLoading.value = true
await recurringInvoiceStore.fetchRecurringInvoices()
isLoading.value = false
setTimeout(() => {
scrollToRecurringInvoice()
}, 500)
}
function scrollToRecurringInvoice() {
const el = document.getElementById(`recurring-invoice-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearched() {
let data = ''
if (
searchData.searchText !== '' &&
searchData.searchText !== null &&
searchData.searchText !== undefined
) {
data += `search=${searchData.searchText}&`
}
if (searchData.orderBy !== null && searchData.orderBy !== undefined) {
data += `orderBy=${searchData.orderBy}&`
}
if (
searchData.orderByField !== null &&
searchData.orderByField !== undefined
) {
data += `orderByField=${searchData.orderByField}`
}
isSearching.value = true
let response = await recurringInvoiceStore.searchRecurringInvoice(data)
isSearching.value = false
if (response.data) {
recurringInvoiceStore.recurringInvoices = response.data.data
}
}
function sortData() {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
onSearched()
return true
}
searchData.orderBy = 'asc'
onSearched()
return true
}
loadRecurringInvoices()
onSearched = debounce(onSearched, 500)
</script>
<template>
<!-- sidebar -->
<div
class="
fixed
top-0
left-0
hidden
h-full
pt-16
pb-4
ml-56
bg-white
xl:ml-64
w-88
xl:block
"
>
<div
class="
flex
items-center
justify-between
px-4
pt-8
pb-2
border border-gray-200 border-solid
height-full
"
>
<div class="mb-6">
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearched()"
>
<template #right>
<BaseIcon name="SearchIcon" class="h-5 text-gray-400" />
</template>
</BaseInput>
</div>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<BaseDropdown class="ml-3" position="bottom-start">
<template #activator>
<BaseButton size="md" variant="gray">
<BaseIcon name="FilterIcon" class="h-5" />
</BaseButton>
</template>
<div
class="
px-2
py-1
pb-2
mb-1 mb-2
text-sm
border-b border-gray-200 border-solid
"
>
{{ $t('general.sort_by') }}
</div>
<BaseDropdownItem class="flex px-1 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_next_invoice_date"
v-model="searchData.orderByField"
:label="$t('recurring_invoices.next_invoice_date')"
size="sm"
name="filter"
value="next_invoice_at"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
<BaseDropdownItem class="flex px-1 py-2 cursor-pointer">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_start_date"
v-model="searchData.orderByField"
:label="$t('recurring_invoices.starts_at')"
value="starts_at"
size="sm"
name="filter"
@update:modelValue="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div
v-if="recurringInvoiceStore && recurringInvoiceStore.recurringInvoices"
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
base-scroll
"
>
<div
v-for="(invoice, index) in recurringInvoiceStore.recurringInvoices"
:key="index"
>
<router-link
v-if="invoice && !isLoading"
:id="'recurring-invoice-' + invoice.id"
:to="`/admin/recurring-invoices/${invoice.id}/view`"
:class="[
'flex justify-between side-invoice p-4 cursor-pointer hover:bg-gray-100 items-center border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid':
hasActiveUrl(invoice.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<BaseText
:text="invoice.customer.name"
:length="30"
class="
pr-2
mb-2
text-sm
not-italic
font-normal
leading-5
text-black
capitalize
truncate
"
/>
<div
class="
mt-1
mb-2
text-xs
not-italic
font-medium
leading-5
text-gray-600
"
>
{{ invoice.invoice_number }}
</div>
<BaseRecurringInvoiceStatusBadge
:status="invoice.status"
class="px-1 text-xs"
>
{{ invoice.status }}
</BaseRecurringInvoiceStatusBadge>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
class="
block
mb-2
text-xl
not-italic
font-semibold
leading-8
text-right text-gray-900
"
:amount="invoice.total"
:currency="invoice.customer.currency"
/>
<div
class="
text-sm
not-italic
font-normal
leading-5
text-right text-gray-600
est-date
"
>
{{ invoice.formatted_starts_at }}
</div>
</div>
</router-link>
</div>
<div class="flex justify-center p-4 items-center">
<LoadingIcon
v-if="isLoading"
class="h-6 m-1 animate-spin text-primary-400"
/>
</div>
<p
v-if="!recurringInvoiceStore.recurringInvoices.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('invoices.no_matching_invoices') }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,230 @@
<template>
<div class="grid gap-8 md:grid-cols-12 pt-10">
<div class="col-span-8 md:col-span-4">
<BaseInputGroup
:label="$t('reports.sales.date_range')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedRange"
:options="dateRange"
@update:modelValue="onChangeDateRange"
/>
</BaseInputGroup>
<div class="flex flex-col mt-6 lg:space-x-3 lg:flex-row">
<BaseInputGroup :label="$t('reports.expenses.from_date')">
<BaseDatePicker v-model="formData.from_date" />
</BaseInputGroup>
<div
class="
hidden
w-5
h-0
mx-4
border border-gray-400 border-solid
xl:block
"
style="margin-top: 2.5rem"
/>
<BaseInputGroup :label="$t('reports.expenses.to_date')">
<BaseDatePicker v-model="formData.to_date" />
</BaseInputGroup>
</div>
<BaseButton
variant="primary-outline"
class="content-center hidden mt-0 w-md md:flex md:mt-8"
type="submit"
@click.prevent="getReports"
>
{{ $t('reports.update_report') }}
</BaseButton>
</div>
<div class="col-span-8">
<iframe
:src="getReportUrl"
class="
hidden
w-full
h-screen h-screen-ios
border-gray-100 border-solid
rounded
md:flex
"
/>
<a
class="
flex
items-center
justify-center
h-10
px-5
py-1
text-sm
font-medium
leading-none
text-center text-white
rounded
whitespace-nowrap
md:hidden
bg-primary-500
cursor-pointer
"
@click="viewReportsPDF"
>
<BaseIcon name="DocumentTextIcon" class="h-5 mr-2" />
<span>{{ $t('reports.view_pdf') }}</span>
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, reactive } from 'vue'
import moment from 'moment'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
globalStore.downloadReport = downloadReport
const dateRange = reactive([
t('dateRange.today'),
t('dateRange.this_week'),
t('dateRange.this_month'),
t('dateRange.this_quarter'),
t('dateRange.this_year'),
t('dateRange.previous_week'),
t('dateRange.previous_month'),
t('dateRange.previous_quarter'),
t('dateRange.previous_year'),
t('dateRange.custom'),
])
const selectedRange = ref(t('dateRange.this_month'))
let range = ref(new Date())
let url = ref(null)
let siteURL = ref(null)
const formData = reactive({
from_date: moment().startOf('month').toString(),
to_date: moment().endOf('month').toString(),
})
const getReportUrl = computed(() => {
return url.value
})
const getSelectedCompany = computed(() => {
return companyStore.selectedCompany
})
const dateRangeUrl = computed(() => {
return `${siteURL.value}?from_date=${moment(formData.from_date).format(
'YYYY-MM-DD'
)}&to_date=${moment(formData.to_date).format('YYYY-MM-DD')}`
})
onMounted(() => {
siteURL.value = `/reports/expenses/${getSelectedCompany.value.unique_hash}`
url.value = dateRangeUrl.value
})
watch(
() => range,
(newRange) => {
formData.from_date = moment(newRange).startOf('year').toString()
formData.to_date = moment(newRange).endOf('year').toString()
}
)
function getThisDate(type, time) {
return moment()[type](time).format('YYYY-MM-DD')
}
function getPreDate(type, time) {
return moment().subtract(1, time)[type](time).format('YYYY-MM-DD')
}
function onChangeDateRange() {
switch (selectedRange.value) {
case 'Today':
formData.from_date = moment().format('YYYY-MM-DD')
formData.to_date = moment().format('YYYY-MM-DD')
break
case 'This Week':
formData.from_date = getThisDate('startOf', 'isoWeek')
formData.to_date = getThisDate('endOf', 'isoWeek')
break
case 'This Month':
formData.from_date = getThisDate('startOf', 'month')
formData.to_date = getThisDate('endOf', 'month')
break
case 'This Quarter':
formData.from_date = getThisDate('startOf', 'quarter')
formData.to_date = getThisDate('endOf', 'quarter')
break
case 'This Year':
formData.from_date = getThisDate('startOf', 'year')
formData.to_date = getThisDate('endOf', 'year')
break
case 'Previous Week':
formData.from_date = getPreDate('startOf', 'isoWeek')
formData.to_date = getPreDate('endOf', 'isoWeek')
break
case 'Previous Month':
formData.from_date = getPreDate('startOf', 'month')
formData.to_date = getPreDate('endOf', 'month')
break
case 'Previous Quarter':
formData.from_date = getPreDate('startOf', 'quarter')
formData.to_date = getPreDate('endOf', 'quarter')
break
case 'Previous Year':
formData.from_date = getPreDate('startOf', 'year')
formData.to_date = getPreDate('endOf', 'year')
break
default:
break
}
}
async function viewReportsPDF() {
let data = await getReports()
window.open(getReportUrl.value, '_blank')
return data
}
function getReports() {
url.value = dateRangeUrl.value
return true
}
function downloadReport() {
if (!getReports()) {
return false
}
window.open(getReportUrl.value + '&download=true')
setTimeout(() => {
url.value = dateRangeUrl.value
}, 200)
}
</script>

View File

@ -0,0 +1,224 @@
<template>
<div class="grid gap-8 md:grid-cols-12 pt-10">
<div class="col-span-8 md:col-span-4">
<BaseInputGroup
:label="$t('reports.profit_loss.date_range')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedRange"
:options="dateRange"
@update:modelValue="onChangeDateRange"
/>
</BaseInputGroup>
<div class="flex flex-col mt-6 lg:space-x-3 lg:flex-row">
<BaseInputGroup :label="$t('reports.profit_loss.from_date')">
<BaseDatePicker v-model="formData.from_date" />
</BaseInputGroup>
<div
class="
hidden
w-5
h-0
mx-4
border border-gray-400 border-solid
xl:block
"
style="margin-top: 2.5rem"
/>
<BaseInputGroup :label="$t('reports.profit_loss.to_date')">
<BaseDatePicker v-model="formData.to_date" />
</BaseInputGroup>
</div>
<BaseButton
variant="primary-outline"
class="content-center hidden mt-0 w-md md:flex md:mt-8"
type="submit"
@click.prevent="getReports"
>
{{ $t('reports.update_report') }}
</BaseButton>
</div>
<div class="col-span-8">
<iframe
:src="getReportUrl"
class="
hidden
w-full
h-screen h-screen-ios
border-gray-100 border-solid
rounded
md:flex
"
/>
<a
class="
flex
items-center
justify-center
h-10
px-5
py-1
text-sm
font-medium
leading-none
text-center text-white
rounded
whitespace-nowrap
md:hidden
bg-primary-500
"
@click="viewReportsPDF"
>
<BaseIcon name="DocumentTextIcon" class="h-5 mr-2" />
<span>{{ $t('reports.view_pdf') }}</span>
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, reactive } from 'vue'
import moment from 'moment'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
globalStore.downloadReport = downloadReport
const dateRange = reactive([
t('dateRange.today'),
t('dateRange.this_week'),
t('dateRange.this_month'),
t('dateRange.this_quarter'),
t('dateRange.this_year'),
t('dateRange.previous_week'),
t('dateRange.previous_month'),
t('dateRange.previous_quarter'),
t('dateRange.previous_year'),
t('dateRange.custom'),
])
const selectedRange = ref(t('dateRange.this_month'))
let url = ref(null)
let siteURL = ref(null)
let range = ref(new Date())
const formData = reactive({
from_date: moment().startOf('month').toString(),
to_date: moment().endOf('month').toString(),
})
const getReportUrl = computed(() => {
return url.value
})
const getSelectedCompany = computed(() => {
return companyStore.selectedCompany
})
const dateRangeUrl = computed(() => {
return `${siteURL.value}?from_date=${moment(formData.from_date).format(
'YYYY-MM-DD'
)}&to_date=${moment(formData.to_date).format('YYYY-MM-DD')}`
})
watch(range, (newRange) => {
formData.from_date = moment(newRange).startOf('year').toString()
formData.to_date = moment(newRange).endOf('year').toString()
})
onMounted(() => {
siteURL.value = `/reports/profit-loss/${getSelectedCompany.value.unique_hash}`
url.value = dateRangeUrl.value
})
function getThisDate(type, time) {
return moment()[type](time).format('YYYY-MM-DD')
}
function getPreDate(type, time) {
return moment().subtract(1, time)[type](time).format('YYYY-MM-DD')
}
function onChangeDateRange() {
switch (selectedRange.value) {
case 'Today':
formData.from_date = moment().format('YYYY-MM-DD')
formData.to_date = moment().format('YYYY-MM-DD')
break
case 'This Week':
formData.from_date = getThisDate('startOf', 'isoWeek')
formData.to_date = getThisDate('endOf', 'isoWeek')
break
case 'This Month':
formData.from_date = getThisDate('startOf', 'month')
formData.to_date = getThisDate('endOf', 'month')
break
case 'This Quarter':
formData.from_date = getThisDate('startOf', 'quarter')
formData.to_date = getThisDate('endOf', 'quarter')
break
case 'This Year':
formData.from_date = getThisDate('startOf', 'year')
formData.to_date = getThisDate('endOf', 'year')
break
case 'Previous Week':
formData.from_date = getPreDate('startOf', 'isoWeek')
formData.to_date = getPreDate('endOf', 'isoWeek')
break
case 'Previous Month':
formData.from_date = getPreDate('startOf', 'month')
formData.to_date = getPreDate('endOf', 'month')
break
case 'Previous Quarter':
formData.from_date = getPreDate('startOf', 'quarter')
formData.to_date = getPreDate('endOf', 'quarter')
break
case 'Previous Year':
formData.from_date = getPreDate('startOf', 'year')
formData.to_date = getPreDate('endOf', 'year')
break
default:
break
}
}
async function viewReportsPDF() {
let data = await getReports()
window.open(getReportUrl.value, '_blank')
return data
}
function getReports() {
url.value = dateRangeUrl.value
return true
}
function downloadReport() {
if (!getReports()) {
return false
}
window.open(getReportUrl.value + '&download=true')
setTimeout(() => {
url.value = dateRangeUrl.value
}, 200)
}
</script>

View File

@ -0,0 +1,263 @@
<template>
<div class="grid gap-8 md:grid-cols-12 pt-10">
<div class="col-span-8 md:col-span-4">
<BaseInputGroup
:label="$t('reports.sales.date_range')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedRange"
:options="dateRange"
@update:modelValue="onChangeDateRange"
/>
</BaseInputGroup>
<div class="flex flex-col my-6 lg:space-x-3 lg:flex-row">
<BaseInputGroup :label="$t('reports.sales.from_date')">
<BaseDatePicker v-model="formData.from_date" />
</BaseInputGroup>
<div
class="
hidden
w-5
h-0
mx-4
border border-gray-400 border-solid
xl:block
"
style="margin-top: 2.5rem"
/>
<BaseInputGroup :label="$t('reports.sales.to_date')">
<BaseDatePicker v-model="formData.to_date" />
</BaseInputGroup>
</div>
<BaseInputGroup
:label="$t('reports.sales.report_type')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedType"
:options="reportTypes"
:placeholder="$t('reports.sales.report_type')"
class="mt-1"
@update:modelValue="getInitialReport"
/>
</BaseInputGroup>
<BaseButton
variant="primary-outline"
class="content-center hidden mt-0 w-md md:flex md:mt-8"
type="submit"
@click.prevent="getReports"
>
{{ $t('reports.update_report') }}
</BaseButton>
</div>
<div class="col-span-8">
<iframe
:src="getReportUrl"
class="
hidden
w-full
h-screen h-screen-ios
border-gray-100 border-solid
rounded
md:flex
"
/>
<a
class="
flex
items-center
justify-center
h-10
px-5
py-1
text-sm
font-medium
leading-none
text-center text-white
rounded
whitespace-nowrap
md:hidden
bg-primary-500
"
@click="viewReportsPDF"
>
<BaseIcon name="DocumentTextIcon" class="h-5 mr-2" />
<span>{{ $t('reports.view_pdf') }}</span>
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, reactive } from 'vue'
import moment from 'moment'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const { t } = useI18n()
const globalStore = useGlobalStore()
globalStore.downloadReport = downloadReport
const dateRange = reactive([
t('dateRange.today'),
t('dateRange.this_week'),
t('dateRange.this_month'),
t('dateRange.this_quarter'),
t('dateRange.this_year'),
t('dateRange.previous_week'),
t('dateRange.previous_month'),
t('dateRange.previous_quarter'),
t('dateRange.previous_year'),
t('dateRange.custom'),
])
const reportTypes = ref(['By Customer', 'By Item'])
const selectedType = ref('By Customer')
const selectedRange = ref(t('dateRange.this_month'))
let range = ref(new Date())
let url = ref(null)
let customerSiteURL = ref(null)
let itemsSiteURL = ref(null)
let formData = reactive({
from_date: moment().startOf('month').format('YYYY-MM-DD').toString(),
to_date: moment().endOf('month').format('YYYY-MM-DD').toString(),
})
const companyStore = useCompanyStore()
const getReportUrl = computed(() => {
return url.value
})
const getSelectedCompany = computed(() => {
return companyStore.selectedCompany
})
const customerDateRangeUrl = computed(() => {
return `${customerSiteURL.value}?from_date=${moment(
formData.from_date
).format('YYYY-MM-DD')}&to_date=${moment(formData.to_date).format(
'YYYY-MM-DD'
)}`
})
const itemDaterangeUrl = computed(() => {
return `${itemsSiteURL.value}?from_date=${moment(formData.from_date).format(
'YYYY-MM-DD'
)}&to_date=${moment(formData.to_date).format('YYYY-MM-DD')}`
})
watch(range, (newRange) => {
formData.from_date = moment(newRange).startOf('year').toString()
formData.to_date = moment(newRange).endOf('year').toString()
})
onMounted(() => {
customerSiteURL.value = `/reports/sales/customers/${getSelectedCompany.value.unique_hash}`
itemsSiteURL.value = `/reports/sales/items/${getSelectedCompany.value.unique_hash}`
getInitialReport()
})
function getThisDate(type, time) {
return moment()[type](time).format('YYYY-MM-DD')
}
function getPreDate(type, time) {
return moment().subtract(1, time)[type](time).format('YYYY-MM-DD')
}
function onChangeDateRange() {
switch (selectedRange.value) {
case 'Today':
formData.from_date = moment().format('YYYY-MM-DD')
formData.to_date = moment().format('YYYY-MM-DD')
break
case 'This Week':
formData.from_date = getThisDate('startOf', 'isoWeek')
formData.to_date = getThisDate('endOf', 'isoWeek')
break
case 'This Month':
formData.from_date = getThisDate('startOf', 'month')
formData.to_date = getThisDate('endOf', 'month')
break
case 'This Quarter':
formData.from_date = getThisDate('startOf', 'quarter')
formData.to_date = getThisDate('endOf', 'quarter')
break
case 'This Year':
formData.from_date = getThisDate('startOf', 'year')
formData.to_date = getThisDate('endOf', 'year')
break
case 'Previous Week':
formData.from_date = getPreDate('startOf', 'isoWeek')
formData.to_date = getPreDate('endOf', 'isoWeek')
break
case 'Previous Month':
formData.from_date = getPreDate('startOf', 'month')
formData.to_date = getPreDate('endOf', 'month')
break
case 'Previous Quarter':
formData.from_date = getPreDate('startOf', 'quarter')
formData.to_date = getPreDate('endOf', 'quarter')
break
case 'Previous Year':
formData.from_date = getPreDate('startOf', 'year')
formData.to_date = getPreDate('endOf', 'year')
break
default:
break
}
}
async function getInitialReport() {
if (selectedType.value === 'By Customer') {
url.value = customerDateRangeUrl.value
return true
}
url.value = itemDaterangeUrl.value
return true
}
async function viewReportsPDF() {
let data = await getReports()
window.open(getReportUrl.value, '_blank')
return data
}
function getReports() {
if (selectedType.value === 'By Customer') {
url.value = customerDateRangeUrl.value
return true
}
url.value = itemDaterangeUrl.value
return true
}
function downloadReport() {
if (!getReports()) {
return false
}
window.open(getReportUrl.value + '&download=true')
setTimeout(() => {
if (selectedType.value === 'By Customer') {
url.value = customerDateRangeUrl.value
return true
}
url.value = itemDaterangeUrl.value
return true
}, 200)
}
</script>

View File

@ -0,0 +1,228 @@
<template>
<div class="grid gap-8 md:grid-cols-12 pt-10">
<div class="col-span-8 md:col-span-4">
<BaseInputGroup
:label="$t('reports.taxes.date_range')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedRange"
:options="dateRange"
@update:modelValue="onChangeDateRange"
/>
</BaseInputGroup>
<div class="flex flex-col mt-6 lg:space-x-3 lg:flex-row">
<BaseInputGroup :label="$t('reports.taxes.from_date')">
<BaseDatePicker v-model="formData.from_date" />
</BaseInputGroup>
<div
class="
hidden
w-5
h-0
mx-4
border border-gray-400 border-solid
xl:block
"
style="margin-top: 2.5rem"
/>
<BaseInputGroup :label="$t('reports.taxes.to_date')">
<BaseDatePicker v-model="formData.to_date" />
</BaseInputGroup>
</div>
<BaseButton
variant="primary-outline"
class="content-center hidden mt-0 w-md md:flex md:mt-8"
type="submit"
@click.prevent="getReports"
>
{{ $t('reports.update_report') }}
</BaseButton>
</div>
<div class="col-span-8">
<iframe
:src="getReportUrl"
class="
hidden
w-full
h-screen h-screen-ios
border-gray-100 border-solid
rounded
md:flex
"
/>
<a
class="
flex
items-center
justify-center
h-10
px-5
py-1
text-sm
font-medium
leading-none
text-center text-white
rounded
whitespace-nowrap
md:hidden
bg-primary-500
"
@click="viewReportsPDF"
>
<BaseIcon name="DocumentTextIcon" class="h-5 mr-2" />
<span>{{ $t('reports.view_pdf') }}</span>
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, reactive } from 'vue'
import moment from 'moment'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const globalStore = useGlobalStore()
globalStore.downloadReport = downloadReport
const { t } = useI18n()
const dateRange = reactive([
t('dateRange.today'),
t('dateRange.this_week'),
t('dateRange.this_month'),
t('dateRange.this_quarter'),
t('dateRange.this_year'),
t('dateRange.previous_week'),
t('dateRange.previous_month'),
t('dateRange.previous_quarter'),
t('dateRange.previous_year'),
t('dateRange.custom'),
])
const selectedRange = ref(t('dateRange.this_month'))
const formData = reactive({
from_date: moment().startOf('month').format('YYYY-MM-DD').toString(),
to_date: moment().endOf('month').format('YYYY-MM-DD').toString(),
})
let url = ref(null)
const getReportUrl = computed(() => {
return url.value
})
const companyStore = useCompanyStore()
const getSelectedCompany = computed(() => {
return companyStore.selectedCompany
})
let siteURL = ref(null)
onMounted(() => {
siteURL.value = `/reports/tax-summary/${getSelectedCompany.value.unique_hash}`
url.value = dateRangeUrl.value
})
const dateRangeUrl = computed(() => {
return `${siteURL.value}?from_date=${moment(formData.from_date).format(
'YYYY-MM-DD'
)}&to_date=${moment(formData.to_date).format('YYYY-MM-DD')}`
})
let range = ref(new Date())
watch(range.value, (newRange) => {
formData.from_date = moment(newRange).startOf('year').toString()
formData.to_date = moment(newRange).endOf('year').toString()
})
function getThisDate(type, time) {
return moment()[type](time).format('YYYY-MM-DD')
}
function getPreDate(type, time) {
return moment().subtract(1, time)[type](time).format('YYYY-MM-DD')
}
function onChangeDateRange() {
switch (selectedRange.value) {
case 'Today':
formData.from_date = moment().format('YYYY-MM-DD')
formData.to_date = moment().format('YYYY-MM-DD')
break
case 'This Week':
formData.from_date = getThisDate('startOf', 'isoWeek')
formData.to_date = getThisDate('endOf', 'isoWeek')
break
case 'This Month':
formData.from_date = getThisDate('startOf', 'month')
formData.to_date = getThisDate('endOf', 'month')
break
case 'This Quarter':
formData.from_date = getThisDate('startOf', 'quarter')
formData.to_date = getThisDate('endOf', 'quarter')
break
case 'This Year':
formData.from_date = getThisDate('startOf', 'year')
formData.to_date = getThisDate('endOf', 'year')
break
case 'Previous Week':
formData.from_date = getPreDate('startOf', 'isoWeek')
formData.to_date = getPreDate('endOf', 'isoWeek')
break
case 'Previous Month':
formData.from_date = getPreDate('startOf', 'month')
formData.to_date = getPreDate('endOf', 'month')
break
case 'Previous Quarter':
formData.from_date = getPreDate('startOf', 'quarter')
formData.to_date = getPreDate('endOf', 'quarter')
break
case 'Previous Year':
formData.from_date = getPreDate('startOf', 'year')
formData.to_date = getPreDate('endOf', 'year')
break
default:
break
}
}
async function viewReportsPDF() {
let data = await getReports()
window.open(getReportUrl.value, '_blank')
return data
}
function getReports() {
url.value = dateRangeUrl.value
return true
}
function downloadReport() {
if (!getReports()) {
return false
}
window.open(getReportUrl.value + '&download=true')
setTimeout(() => {
url.value = dateRangeUrl.value
}, 200)
}
</script>

View File

@ -0,0 +1,65 @@
<template>
<BasePage>
<BasePageHeader :title="$tc('reports.report', 2)">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$tc('reports.report', 2)"
to="/admin/reports"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton variant="primary" class="ml-4" @click="onDownload">
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
</template>
{{ $t('reports.download_pdf') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Tabs -->
<BaseTabGroup class="p-2">
<BaseTab
:title="$t('reports.sales.sales')"
tab-panel-container="px-0 py-0"
>
<SalesReport ref="report" />
</BaseTab>
<BaseTab
:title="$t('reports.profit_loss.profit_loss')"
tab-panel-container="px-0 py-0"
>
<ProfitLossReport ref="report" />
</BaseTab>
<BaseTab
:title="$t('reports.expenses.expenses')"
tab-panel-container="px-0 py-0"
>
<ExpenseReport ref="report" />
</BaseTab>
<BaseTab
:title="$t('reports.taxes.taxes')"
tab-panel-container="px-0 py-0"
>
<TaxReport ref="report" />
</BaseTab>
</BaseTabGroup>
</BasePage>
</template>
<script setup>
import { ref } from 'vue'
import SalesReport from '../SalesReports.vue'
import ExpenseReport from '../ExpensesReport.vue'
import ProfitLossReport from '../ProfitLossReport.vue'
import TaxReport from '../TaxReport.vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
const globalStore = useGlobalStore()
function onDownload() {
globalStore.downloadReport()
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,134 @@
<template>
<form @submit.prevent="submitForm">
<h6 class="text-gray-900 text-lg font-medium">
{{ $t('settings.customization.invoices.due_date') }}
</h6>
<p class="mt-1 text-sm text-gray-500 mb-2">
{{ $t('settings.customization.invoices.due_date_description') }}
</p>
<BaseSwitchSection
v-model="dueDateAutoField"
:title="$t('settings.customization.invoices.set_due_date_automatically')"
:description="
$t(
'settings.customization.invoices.set_due_date_automatically_description'
)
"
/>
<BaseInputGroup
v-if="dueDateAutoField"
:label="$t('settings.customization.invoices.due_date_days')"
:error="
v$.dueDateSettings.invoice_due_date_days.$error &&
v$.dueDateSettings.invoice_due_date_days.$errors[0].$message
"
class="mt-2 mb-4"
>
<div class="w-full sm:w-1/2 md:w-1/4 lg:w-1/5">
<BaseInput
v-model="dueDateSettings.invoice_due_date_days"
:invalid="v$.dueDateSettings.invoice_due_date_days.$error"
type="number"
@input="v$.dueDateSettings.invoice_due_date_days.$touch()"
/>
</div>
</BaseInputGroup>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :class="slotProps.class" name="SaveIcon" />
</template>
{{ $t('settings.customization.save') }}
</BaseButton>
</form>
</template>
<script setup>
import { ref, computed, onMounted, reactive, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { numeric, helpers, requiredIf } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
const { t } = useI18n()
const companyStore = useCompanyStore()
const utils = inject('utils')
let isSaving = ref(false)
const dueDateSettings = reactive({
invoice_set_due_date_automatically: null,
invoice_due_date_days: null,
})
utils.mergeSettings(dueDateSettings, {
...companyStore.selectedCompanySettings,
})
const dueDateAutoField = computed({
get: () => {
return dueDateSettings.invoice_set_due_date_automatically === 'YES'
},
set: async (newValue) => {
const value = newValue ? 'YES' : 'NO'
dueDateSettings.invoice_set_due_date_automatically = value
},
})
const rules = computed(() => {
return {
dueDateSettings: {
invoice_due_date_days: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(dueDateAutoField.value)
),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
},
}
})
const v$ = useVuelidate(rules, { dueDateSettings })
async function submitForm() {
v$.value.dueDateSettings.$touch()
if (v$.value.dueDateSettings.$invalid) {
return false
}
isSaving.value = true
let data = {
settings: {
...dueDateSettings,
},
}
// Don't pass due_date_days if setting is not enabled
if (!dueDateAutoField.value) {
delete data.settings.invoice_due_date_days
}
await companyStore.updateCompanySettings({
data,
message: 'settings.customization.invoices.invoice_settings_updated',
})
isSaving.value = false
return true
}
</script>

View File

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

View File

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

View File

@ -0,0 +1,131 @@
<template>
<ItemUnitModal />
<div class="flex flex-wrap justify-end mt-2 lg:flex-nowrap">
<BaseButton variant="primary-outline" @click="addItemUnit">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.customization.items.add_item_unit') }}
</BaseButton>
</div>
<BaseTable ref="table" class="mt-10" :data="fetchData" :columns="columns">
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<div class="inline-block">
<BaseIcon name="DotsHorizontalIcon" class="text-gray-500" />
</div>
</template>
<BaseDropdownItem @click="editItemUnit(row)">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removeItemUnit(row)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useItemStore } from '@/scripts/admin/stores/item'
import { useModalStore } from '@/scripts/stores/modal'
import { useDialogStore } from '@/scripts/stores/dialog'
import ItemUnitModal from '@/scripts/admin/components/modal-components/ItemUnitModal.vue'
const { t } = useI18n()
const table = ref(null)
const itemStore = useItemStore()
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const columns = computed(() => {
return [
{
key: 'name',
label: t('settings.customization.items.unit_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
async function fetchData({ page, filter, sort }) {
let data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
let response = await itemStore.fetchItemUnits(data)
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 5,
},
}
}
async function addItemUnit() {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
refreshData: table.value.refresh,
size: 'sm',
})
}
async function editItemUnit(row) {
itemStore.fetchItemUnit(row.data.id)
modalStore.openModal({
title: t('settings.customization.items.edit_item_unit'),
componentName: 'ItemUnitModal',
id: row.data.id,
data: row.data,
refreshData: table.value && table.value.refresh,
})
}
function removeItemUnit(row) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.customization.items.item_unit_confirm_delete'),
yesLabel: t('general.yes'),
noLabel: t('general.no'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res) => {
if (res) {
await itemStore.deleteItemUnit(row.data.id)
table.value && table.value.refresh()
}
})
}
</script>

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More