mirror of
https://github.com/crater-invoice/crater.git
synced 2025-10-28 04:01:10 -04:00
v6 update
This commit is contained in:
175
resources/scripts/admin/views/SampleTable.vue
Normal file
175
resources/scripts/admin/views/SampleTable.vue
Normal 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>
|
||||
92
resources/scripts/admin/views/auth/ForgotPassword.vue
Normal file
92
resources/scripts/admin/views/auth/ForgotPassword.vue
Normal 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>
|
||||
129
resources/scripts/admin/views/auth/Login.vue
Normal file
129
resources/scripts/admin/views/auth/Login.vue
Normal 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>
|
||||
165
resources/scripts/admin/views/auth/ResetPassword.vue
Normal file
165
resources/scripts/admin/views/auth/ResetPassword.vue
Normal 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>
|
||||
756
resources/scripts/admin/views/customers/Create.vue
Normal file
756
resources/scripts/admin/views/customers/Create.vue
Normal 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>
|
||||
368
resources/scripts/admin/views/customers/Index.vue
Normal file
368
resources/scripts/admin/views/customers/Index.vue
Normal 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>
|
||||
144
resources/scripts/admin/views/customers/View.vue
Normal file
144
resources/scripts/admin/views/customers/View.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
28
resources/scripts/admin/views/dashboard/Dashboard.vue
Normal file
28
resources/scripts/admin/views/dashboard/Dashboard.vue
Normal 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>
|
||||
186
resources/scripts/admin/views/dashboard/DashboardChart.vue
Normal file
186
resources/scripts/admin/views/dashboard/DashboardChart.vue
Normal 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>
|
||||
@ -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>
|
||||
79
resources/scripts/admin/views/dashboard/DashboardStats.vue
Normal file
79
resources/scripts/admin/views/dashboard/DashboardStats.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
180
resources/scripts/admin/views/dashboard/DashboardTable.vue
Normal file
180
resources/scripts/admin/views/dashboard/DashboardTable.vue
Normal 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>
|
||||
64
resources/scripts/admin/views/errors/404.vue
Normal file
64
resources/scripts/admin/views/errors/404.vue
Normal 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>
|
||||
487
resources/scripts/admin/views/estimates/Index.vue
Normal file
487
resources/scripts/admin/views/estimates/Index.vue
Normal 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>
|
||||
501
resources/scripts/admin/views/estimates/View.vue
Normal file
501
resources/scripts/admin/views/estimates/View.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
475
resources/scripts/admin/views/expenses/Create.vue
Normal file
475
resources/scripts/admin/views/expenses/Create.vue
Normal 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>
|
||||
405
resources/scripts/admin/views/expenses/Index.vue
Normal file
405
resources/scripts/admin/views/expenses/Index.vue
Normal 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>
|
||||
110
resources/scripts/admin/views/installation/Installation.vue
Normal file
110
resources/scripts/admin/views/installation/Installation.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
108
resources/scripts/admin/views/installation/Step4VerifyDomain.vue
Normal file
108
resources/scripts/admin/views/installation/Step4VerifyDomain.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
247
resources/scripts/admin/views/installation/Step7CompanyInfo.vue
Normal file
247
resources/scripts/admin/views/installation/Step7CompanyInfo.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
539
resources/scripts/admin/views/invoices/Index.vue
Normal file
539
resources/scripts/admin/views/invoices/Index.vue
Normal 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>
|
||||
483
resources/scripts/admin/views/invoices/View.vue
Normal file
483
resources/scripts/admin/views/invoices/View.vue
Normal 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>
|
||||
280
resources/scripts/admin/views/invoices/create/InvoiceCreate.vue
Normal file
280
resources/scripts/admin/views/invoices/create/InvoiceCreate.vue
Normal 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>
|
||||
@ -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>
|
||||
312
resources/scripts/admin/views/items/Create.vue
Normal file
312
resources/scripts/admin/views/items/Create.vue
Normal 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>
|
||||
359
resources/scripts/admin/views/items/Index.vue
Normal file
359
resources/scripts/admin/views/items/Index.vue
Normal 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>
|
||||
230
resources/scripts/admin/views/modules/Index.vue
Normal file
230
resources/scripts/admin/views/modules/Index.vue
Normal 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>
|
||||
1022
resources/scripts/admin/views/modules/View.vue
Normal file
1022
resources/scripts/admin/views/modules/View.vue
Normal file
File diff suppressed because it is too large
Load Diff
140
resources/scripts/admin/views/modules/partials/ModuleCard.vue
Normal file
140
resources/scripts/admin/views/modules/partials/ModuleCard.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
527
resources/scripts/admin/views/payments/Create.vue
Normal file
527
resources/scripts/admin/views/payments/Create.vue
Normal 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>
|
||||
380
resources/scripts/admin/views/payments/Index.vue
Normal file
380
resources/scripts/admin/views/payments/Index.vue
Normal 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>
|
||||
436
resources/scripts/admin/views/payments/View.vue
Normal file
436
resources/scripts/admin/views/payments/View.vue
Normal 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>
|
||||
496
resources/scripts/admin/views/recurring-invoices/Index.vue
Normal file
496
resources/scripts/admin/views/recurring-invoices/Index.vue
Normal 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>
|
||||
76
resources/scripts/admin/views/recurring-invoices/View.vue
Normal file
76
resources/scripts/admin/views/recurring-invoices/View.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
230
resources/scripts/admin/views/reports/ExpensesReport.vue
Normal file
230
resources/scripts/admin/views/reports/ExpensesReport.vue
Normal 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>
|
||||
224
resources/scripts/admin/views/reports/ProfitLossReport.vue
Normal file
224
resources/scripts/admin/views/reports/ProfitLossReport.vue
Normal 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>
|
||||
263
resources/scripts/admin/views/reports/SalesReports.vue
Normal file
263
resources/scripts/admin/views/reports/SalesReports.vue
Normal 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>
|
||||
228
resources/scripts/admin/views/reports/TaxReport.vue
Normal file
228
resources/scripts/admin/views/reports/TaxReport.vue
Normal 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>
|
||||
65
resources/scripts/admin/views/reports/layout/Index.vue
Normal file
65
resources/scripts/admin/views/reports/layout/Index.vue
Normal 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>
|
||||
228
resources/scripts/admin/views/settings/AccountSetting.vue
Normal file
228
resources/scripts/admin/views/settings/AccountSetting.vue
Normal 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>
|
||||
230
resources/scripts/admin/views/settings/BackupSetting.vue
Normal file
230
resources/scripts/admin/views/settings/BackupSetting.vue
Normal 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>
|
||||
260
resources/scripts/admin/views/settings/CompanyInfoSettings.vue
Normal file
260
resources/scripts/admin/views/settings/CompanyInfoSettings.vue
Normal 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>
|
||||
150
resources/scripts/admin/views/settings/CustomFieldsSetting.vue
Normal file
150
resources/scripts/admin/views/settings/CustomFieldsSetting.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
257
resources/scripts/admin/views/settings/FileDiskSetting.vue
Normal file
257
resources/scripts/admin/views/settings/FileDiskSetting.vue
Normal 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>
|
||||
93
resources/scripts/admin/views/settings/MailConfigSetting.vue
Normal file
93
resources/scripts/admin/views/settings/MailConfigSetting.vue
Normal 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>
|
||||
116
resources/scripts/admin/views/settings/NotesSetting.vue
Normal file
116
resources/scripts/admin/views/settings/NotesSetting.vue
Normal 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>
|
||||
162
resources/scripts/admin/views/settings/NotificationsSetting.vue
Normal file
162
resources/scripts/admin/views/settings/NotificationsSetting.vue
Normal 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>
|
||||
104
resources/scripts/admin/views/settings/PaymentsModeSetting.vue
Normal file
104
resources/scripts/admin/views/settings/PaymentsModeSetting.vue
Normal 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>
|
||||
331
resources/scripts/admin/views/settings/PreferencesSetting.vue
Normal file
331
resources/scripts/admin/views/settings/PreferencesSetting.vue
Normal 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>
|
||||
112
resources/scripts/admin/views/settings/RolesSettings.vue
Normal file
112
resources/scripts/admin/views/settings/RolesSettings.vue
Normal 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>
|
||||
95
resources/scripts/admin/views/settings/SettingsIndex.vue
Normal file
95
resources/scripts/admin/views/settings/SettingsIndex.vue
Normal 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>
|
||||
182
resources/scripts/admin/views/settings/TaxTypesSetting.vue
Normal file
182
resources/scripts/admin/views/settings/TaxTypesSetting.vue
Normal 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>
|
||||
436
resources/scripts/admin/views/settings/UpdateAppSetting.vue
Normal file
436
resources/scripts/admin/views/settings/UpdateAppSetting.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
Reference in New Issue
Block a user