v6 update

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

View File

@ -0,0 +1,12 @@
<template>
<div class="h-10 w-20 bg-gray-100">
<slot></slot>
</div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,15 @@
<template>
<div class="bg-blue-100 h-screen container mx-auto px-6">
<h1 class="text-xl font-bold">Samplez Pages</h1>
<BaseButton>Hello </BaseButton>
<BaseCheckon>{{ customerStore.customers }} </BaseCheckon>
</div>
</template>
<script setup>
import BaseCheckon from '@/scripts/customer/BaseCheckon.vue'
import { useCustomerStore } from '@/scripts/customer/stores/customer'
const customerStore = useCustomerStore()
</script>

View File

@ -0,0 +1,100 @@
<template>
<form id="loginForm" @submit.prevent="validateBeforeSubmit">
<BaseInputGroup
:error="v$.email.$error && v$.email.$errors[0].$message"
:label="$t('login.enter_email')"
class="mb-4"
required
>
<BaseInput
v-model="formData.email"
type="email"
name="email"
:invalid="v$.email.$error"
@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 setup>
import { reactive, ref, computed } from 'vue'
import { required, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/scripts/customer/stores/auth'
import { useRouter, useRoute } from 'vue-router'
// // store
const authStore = useAuthStore()
const { t } = useI18n()
const route = useRoute()
// local state
const formData = reactive({
email: '',
company: '',
})
const isSent = ref(false)
const isLoading = ref(false)
// validation
const rules = computed(() => {
return {
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
}
})
const v$ = useVuelidate(rules, formData)
// methods
function validateBeforeSubmit(e) {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isLoading.value = true
let data = {
...formData,
company: route.params.company,
}
authStore
.forgotPassword(data)
.then((res) => {
isLoading.value = false
})
.catch((err) => {
isLoading.value = false
})
isSent.value = true
}
</script>

View File

@ -0,0 +1,139 @@
<template>
<form
id="loginForm"
class="space-y-6"
action="#"
method="POST"
@submit.prevent="validateBeforeSubmit"
>
<BaseInputGroup
:error="
v$.loginData.email.$error && v$.loginData.email.$errors[0].$message
"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.email"
type="email"
:invalid="v$.loginData.email.$error"
@input="v$.loginData.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.loginData.password.$error &&
v$.loginData.password.$errors[0].$message
"
:label="$t('login.password')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.password"
:type="getInputType"
:invalid="v$.loginData.password.$error"
@input="v$.loginData.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="flex items-center justify-between">
<router-link
:to="{ name: 'customer.forgot-password' }"
class="text-sm text-primary-600 hover:text-gray-500"
>
{{ $t('login.forgot_password') }}
</router-link>
</div>
<div>
<BaseButton
:loading="isLoading"
:disabled="isLoading"
type="submit"
class="w-full justify-center"
>
<template #left="slotProps">
<BaseIcon name="LockClosedIcon" :class="slotProps.class" />
</template>
{{ $t('login.login') }}
</BaseButton>
</div>
</form>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVuelidate } from '@vuelidate/core'
import { required, email, helpers } from '@vuelidate/validators'
import { useAuthStore } from '@/scripts/customer/stores/auth'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { t } = useI18n()
let isLoading = ref(false)
const isShowPassword = ref(false)
const getInputType = computed(() => {
if (isShowPassword.value) {
return 'text'
}
return 'password'
})
const rules = computed(() => {
return {
loginData: {
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, authStore)
async function validateBeforeSubmit() {
v$.value.loginData.$touch()
if (v$.value.loginData.$invalid) {
return true
}
isLoading.value = true
let data = {
...authStore.loginData,
company: route.params.company,
}
try {
await authStore.login(data)
isLoading.value = false
return router.push({ name: 'customer.dashboard' })
authStore.$reset()
} catch (error) {
isLoading.value = false
}
}
</script>

View File

@ -0,0 +1,141 @@
<template>
<form id="loginForm" @submit.prevent="onSubmit">
<BaseInputGroup
:error="v$.email.$error && v$.email.$errors[0].$message"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="loginData.email"
type="email"
name="email"
:invalid="v$.email.$error"
@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="loginData.password"
:type="isShowPassword ? 'text' : 'password'"
name="password"
:invalid="v$.password.$error"
@input="v$.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
:error="
v$.password_confirmation.$error &&
v$.password_confirmation.$errors[0].$message
"
:label="$t('login.retype_password')"
class="mb-4"
required
>
<BaseInput
v-model="loginData.password_confirmation"
type="password"
name="password"
:invalid="v$.password_confirmation.$error"
@input="v$.password_confirmation.$touch()"
/>
</BaseInputGroup>
<BaseButton type="submit" variant="primary">
{{ $t('login.reset_password') }}
</BaseButton>
</form>
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import useVuelidate from '@vuelidate/core'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import {
required,
helpers,
minLength,
sameAs,
email,
} from '@vuelidate/validators'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/scripts/customer/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { t } = useI18n()
const loginData = reactive({
email: '',
password: '',
password_confirmation: '',
})
const globalStore = useGlobalStore()
let isShowPassword = ref(false)
let isLoading = ref(false)
const rules = computed(() => {
return {
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)
),
},
password_confirmation: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(loginData.password)
),
},
}
})
const v$ = useVuelidate(rules, loginData)
async function onSubmit(e) {
v$.value.$touch()
if (!v$.value.$invalid) {
let data = {
email: loginData.email,
password: loginData.password,
password_confirmation: loginData.password_confirmation,
token: route.params.token,
}
isLoading.value = true
let res = authStore.resetPassword(data, route.params.company)
isLoading.value = false
if (res.data) {
router.push({ name: 'customer.login' })
}
}
}
</script>

View File

@ -0,0 +1,11 @@
<template>
<BasePage>
<DashboardStats />
<DashboardTable />
</BasePage>
</template>
<script setup>
import DashboardStats from '@/scripts/customer/views/dashboard/DashboardStats.vue'
import DashboardTable from '@/scripts/customer/views/dashboard/DashboardTable.vue'
</script>

View File

@ -0,0 +1,66 @@
<template>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-9 xl:gap-8">
<!-- Amount Due -->
<DashboardStatsItem
:icon-component="DollarIcon"
:loading="!globalStore.getDashboardDataLoaded"
:route="{ name: 'invoices.dashboard' }"
:large="true"
:label="$t('dashboard.cards.due_amount')"
>
<BaseFormatMoney
:amount="dashboardStore.totalDueAmount"
:currency="globalStore.currency"
/>
</DashboardStatsItem>
<!-- Invoices -->
<DashboardStatsItem
:icon-component="InvoiceIcon"
:loading="!globalStore.getDashboardDataLoaded"
:route="{ name: 'invoices.dashboard' }"
:label="$t('dashboard.cards.invoices')"
>
{{ dashboardStore.invoiceCount }}
</DashboardStatsItem>
<!-- Estimates -->
<DashboardStatsItem
:icon-component="EstimateIcon"
:loading="!globalStore.getDashboardDataLoaded"
:route="{ name: 'estimates.dashboard' }"
:label="$t('dashboard.cards.estimates')"
>
{{ dashboardStore.estimateCount }}
</DashboardStatsItem>
<!-- Payments -->
<DashboardStatsItem
:icon-component="PaymentIcon"
:loading="!globalStore.getDashboardDataLoaded"
:route="{ name: 'payments.dashboard' }"
:label="$t('dashboard.cards.payments')"
>
{{ dashboardStore.paymentCount }}
</DashboardStatsItem>
</div>
</template>
<script setup>
import { inject } from 'vue'
import DollarIcon from '@/scripts/components/icons/dashboard/DollarIcon.vue'
import InvoiceIcon from '@/scripts/components/icons/dashboard/InvoiceIcon.vue'
import PaymentIcon from '@/scripts/components/icons/dashboard/PaymentIcon.vue'
import EstimateIcon from '@/scripts/components/icons/dashboard/EstimateIcon.vue'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useDashboardStore } from '@/scripts/customer/stores/dashboard'
import DashboardStatsItem from '@/scripts/customer/views/dashboard/DashboardStatsItem.vue'
//store
const utils = inject('utils')
const globalStore = useGlobalStore()
const dashboardStore = useDashboardStore()
dashboardStore.loadData()
</script>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,149 @@
<template>
<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({ name: 'invoices.dashboard' })"
>
{{ $t('dashboard.recent_invoices_card.view_all') }}
</BaseButton>
</div>
<!-- Recent Invoice-->
<BaseTable
:data="dashboardStore.recentInvoices"
:columns="dueInvoiceColumns"
:loading="!globalStore.getDashboardDataLoaded"
>
<template #cell-invoice_number="{ row }">
<router-link
:to="{
path: `/${globalStore.companySlug}/customer/invoices/${row.data.id}/view`,
}"
class="font-medium text-primary-500"
>
{{ row.data.invoice_number }}
</router-link>
</template>
<template #cell-paid_status="{ row }">
<BasePaidStatusBadge :status="row.data.paid_status">
{{ row.data.paid_status }}
</BasePaidStatusBadge>
</template>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.due_amount"
:currency="globalStore.currency"
/>
</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({ name: 'estimates.dashboard' })"
>
{{ $t('dashboard.recent_estimate_card.view_all') }}
</BaseButton>
</div>
<BaseTable
:data="dashboardStore.recentEstimates"
:columns="recentEstimateColumns"
:loading="!globalStore.getDashboardDataLoaded"
>
<template #cell-estimate_number="{ row }">
<router-link
:to="{
path: `/${globalStore.companySlug}/customer/estimates/${row.data.id}/view`,
}"
class="font-medium text-primary-500"
>
{{ row.data.estimate_number }}
</router-link>
</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="globalStore.currency"
/>
</template>
</BaseTable>
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useDashboardStore } from '@/scripts/customer/stores/dashboard'
import BaseTable from '@/scripts/components/base/base-table/BaseTable.vue'
// store
const globalStore = useGlobalStore()
const dashboardStore = useDashboardStore()
const { tm, t } = useI18n()
const utils = inject('utils')
const route = useRoute()
//computed prop
const dueInvoiceColumns = computed(() => {
return [
{
key: 'formattedDueDate',
label: t('dashboard.recent_invoices_card.due_on'),
},
{
key: 'invoice_number',
label: t('invoices.number'),
},
{ key: 'paid_status', label: t('invoices.status') },
{
key: 'due_amount',
label: t('dashboard.recent_invoices_card.amount_due'),
},
]
})
const recentEstimateColumns = computed(() => {
return [
{
key: 'formattedEstimateDate',
label: t('dashboard.recent_estimate_card.date'),
},
{
key: 'estimate_number',
label: t('estimates.number'),
},
{ key: 'status', label: t('estimates.status') },
{
key: 'total',
label: t('dashboard.recent_estimate_card.amount_due'),
},
]
})
</script>

View File

@ -0,0 +1,270 @@
<template>
<BasePage>
<!-- Page Header -->
<BasePageHeader :title="$t('estimates.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${globalStore.companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem
:title="$tc('estimates.estimate', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-if="estimateStore.totalEstimates"
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>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$t('estimates.status')" class="px-3">
<BaseSelectInput
v-model="filters.status"
:options="status"
searchable
:show-labels="false"
:allow-empty="false"
:placeholder="$t('general.select_a_status')"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('estimates.estimate_number')"
color="black-light"
class="px-3 mt-2"
>
<BaseInput v-model="filters.estimate_number">
<BaseIcon name="DotsHorizontalIcon" class="h-5 text-gray-500" />
<BaseIcon name="HashtagIcon" class="h-5 mr-3 text-gray-600" />
</BaseInput>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')" class="px-3">
<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="px-3">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('estimates.no_estimates')"
:description="$t('estimates.list_of_estimates')"
>
<ObservatoryIcon class="mt-5 mb-4" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="estimateColumns"
:placeholder-count="estimateStore.totalEstimates >= 20 ? 10 : 5"
class="mt-10"
>
<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-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" />
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<BaseIcon name="DotsHorizontalIcon" class="h-5 text-gray-500" />
</template>
<router-link :to="`estimates/${row.data.id}/view`">
<BaseDropdownItem>
<BaseIcon name="EyeIcon" class="h-5 mr-3 text-gray-600" />
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { debouncedWatch } from '@vueuse/core'
import BaseTable from '@/scripts/components/base/base-table/BaseTable.vue'
import { ref, computed, reactive, inject } from 'vue'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useEstimateStore } from '@/scripts/customer/stores/estimate'
import { useRoute } from 'vue-router'
import ObservatoryIcon from '@/scripts/components/icons/empty/ObservatoryIcon.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// utils
const utils = inject('utils')
const route = useRoute()
// Local state
const table = ref(null)
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
const status = ref([
'DRAFT',
'SENT',
'VIEWED',
'EXPIRED',
'ACCEPTED',
'REJECTED',
])
const filters = reactive({
status: '',
from_date: '',
to_date: '',
estimate_number: '',
})
// store
const globalStore = useGlobalStore()
const estimateStore = useEstimateStore()
// computed
const estimateColumns = computed(() => {
return [
{
key: 'estimate_date',
label: t('estimates.date'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'estimate_number', label: t('estimates.number', 2) },
{ key: 'status', label: t('estimates.status') },
{ key: 'total', label: t('estimates.total') },
{
key: 'actions',
thClass: 'text-right',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const showEmptyScreen = computed(() => {
return !estimateStore.totalEstimates && !isFetchingInitialData.value
})
const currency = computed(() => {
return globalStore.currency
})
// watch
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
// methods
function refreshTable() {
table.value.refresh()
}
function setFilters() {
refreshTable()
}
function clearFilter() {
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.estimate_number = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function fetchData({ page, sort }) {
let data = {
status: filters.status,
estimate_number: filters.estimate_number,
from_date: filters.from_date,
to_date: filters.to_date,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await estimateStore.fetchEstimate(
data,
globalStore.companySlug
)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
</script>

View File

@ -0,0 +1,437 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle.estimate_number">
<template #actions>
<div class="mr-3 text-sm">
<BaseButton
v-if="estimateStore.selectedViewEstimate.status === 'DRAFT'"
variant="primary"
@click="AcceptEstimate"
>
{{ $t('estimates.accept_estimate') }}
</BaseButton>
</div>
<div class="mr-3 text-sm">
<BaseButton
v-if="estimateStore.selectedViewEstimate.status === 'DRAFT'"
variant="primary-outline"
@click="RejectEstimate"
>
{{ $t('estimates.reject_estimate') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 bg-white 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.estimate_number"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearch"
>
<template #right>
<BaseIcon name="SearchIcon" class="h-5 text-gray-400" />
</template>
</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" class="h-5" />
</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="rounded-md pt-3 hover:rounded-md">
<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"
@change="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<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="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<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="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" variant="white" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
sw-scroll
"
>
<router-link
v-for="(estimate, index) in estimateStore.estimates"
:id="'estimate-' + estimate.id"
:key="index"
:to="`/${globalStore.companySlug}/customer/estimates/${estimate.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(estimate.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<div
class="
mb-1
text-md
not-italic
font-medium
leading-5
text-gray-500
capitalize
"
>
{{ estimate.estimate_number }}
</div>
<BaseEstimateStatusBadge :status="estimate.status">
{{ estimate.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="estimate.total"
:currency="estimate.currency"
/>
<div class="text-sm text-right text-gray-500 non-italic">
{{ estimate.formatted_estimate_date }}
</div>
</div>
</router-link>
<p
v-if="!estimateStore.estimates.length"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('estimates.no_matching_estimates') }}
</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 BaseDropdown from '@/scripts/components/base/BaseDropdown.vue'
import BaseDropdownItem from '@/scripts/components/base/BaseDropdownItem.vue'
import { debounce } from 'lodash'
import { ref, reactive, computed, inject, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNotificationStore } from '@/scripts/stores/notification'
import moment from 'moment'
import { useEstimateStore } from '@/scripts/customer/stores/estimate'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useDialogStore } from '@/scripts/stores/dialog'
// Router
const route = useRoute()
const router = useRouter()
// store
const estimateStore = useEstimateStore()
const globalStore = useGlobalStore()
const dialogStore = useDialogStore()
const { tm, t } = useI18n()
// local state
let estimate = reactive({})
let searchData = reactive({
orderBy: '',
orderByField: '',
estimate_number: '',
})
let isSearching = ref(false)
//Utils
const utils = inject('utils')
//Store
const notificationStore = useNotificationStore()
// Computed Props
const pageTitle = computed(() => {
return estimateStore.selectedViewEstimate
})
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() =>
getOrderBy.value ? tm('general.ascending') : tm('general.descending')
)
const shareableLink = computed(() => {
return estimate.unique_hash ? `/estimates/pdf/${estimate.unique_hash}` : false
})
// Watcher
watch(route, () => {
loadEstimate()
})
// Created
loadEstimates()
loadEstimate()
onSearch = debounce(onSearch, 500)
// Methods
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadEstimates() {
await estimateStore.fetchEstimate({ limit: 'all' }, globalStore.companySlug)
setTimeout(() => {
scrollToEstimate()
}, 500)
}
async function loadEstimate() {
if (route && route.params.id) {
let response = await estimateStore.fetchViewEstimate(
{
id: route.params.id,
},
globalStore.companySlug
)
if (response.data) {
Object.assign(estimate, response.data.data)
}
}
}
function scrollToEstimate() {
const el = document.getElementById(`estimate-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch() {
let data = {}
if (
searchData.estimate_number !== '' &&
searchData.estimate_number !== null &&
searchData.estimate_number !== undefined
) {
data.estimate_number = searchData.estimate_number
}
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 estimateStore.searchEstimate(
data,
globalStore.companySlug
)
isSearching.value = false
if (response.data.data) {
estimateStore.estimates = 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 AcceptEstimate() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_mark_as_accepted', 1),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'primary',
size: 'lg',
hideNoButton: false,
})
.then(async (res) => {
let data = {
slug: globalStore.companySlug,
id: route.params.id,
status: 'ACCEPTED',
}
if (res) {
estimateStore.acceptEstimate(data)
router.push({ name: 'estimates.dashboard' })
}
})
}
async function RejectEstimate() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_mark_as_rejected', 1),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'primary',
size: 'lg',
hideNoButton: false,
})
.then(async (res) => {
let data = {
slug: globalStore.companySlug,
id: route.params.id,
status: 'REJECTED',
}
if (res) {
estimateStore.rejectEstimate(data)
router.push({ name: 'estimates.dashboard' })
}
})
}
function copyPdfUrl() {
let pdfUrl = `${window.location.origin}/estimates/pdf/${estimate?.unique_hash}`
utils.copyTextToClipboard(pdfUrl)
notificationStore.showNotification({
type: 'success',
message: tm('general.copied_pdf_url_clipboard'),
})
}
</script>

View File

@ -0,0 +1,275 @@
<template>
<BasePage>
<!-- Page Header -->
<BasePageHeader :title="$t('invoices.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${globalStore.companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem :title="$tc('invoices.invoice', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="invoiceStore.totalInvoices"
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>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$t('invoices.status')" class="px-3">
<BaseSelectInput
v-model="filters.status"
:options="status"
searchable
:allow-empty="false"
:placeholder="$t('general.select_a_status')"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('invoices.invoice_number')"
color="black-light"
class="px-3 mt-2"
>
<BaseInput v-model="filters.invoice_number">
<BaseIcon name="DotsHorizontalIcon" class="h-5 text-gray-500" />
<BaseIcon name="HashtagIcon" class="h-5 ml-3 text-gray-600" />
</BaseInput>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')" class="px-3">
<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="px-3">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('invoices.no_invoices')"
:description="$t('invoices.list_of_invoices')"
>
<MoonwalkerIcon class="mt-5 mb-4" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="itemColumns"
:placeholder-count="invoiceStore.totalInvoices >= 20 ? 10 : 5"
class="mt-10"
>
<template #cell-invoice_date="{ row }">
{{ row.data.formatted_invoice_date }}
</template>
<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>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<template #cell-status="{ row }">
<BaseInvoiceStatusBadge :status="row.data.status" class="px-3 py-1">
{{ row.data.status }}
</BaseInvoiceStatusBadge>
</template>
<template #cell-paid_status="{ row }">
<BaseInvoiceStatusBadge
:status="row.data.paid_status"
class="px-3 py-1"
>
{{ row.data.paid_status }}
</BaseInvoiceStatusBadge>
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<BaseIcon name="DotsHorizontalIcon" class="h-5 text-gray-500" />
</template>
<router-link :to="`invoices/${row.data.id}/view`">
<BaseDropdownItem>
<BaseIcon name="EyeIcon" class="h-5 mr-3 text-gray-600" />
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { useInvoiceStore } from '@/scripts/customer/stores/invoice'
import { debouncedWatch } from '@vueuse/core'
import BaseTable from '@/scripts/components/base/base-table/BaseTable.vue'
import { ref, computed, reactive, inject, onMounted } from 'vue'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useRoute } from 'vue-router'
import MoonwalkerIcon from '@/scripts/components/icons/empty/MoonwalkerIcon.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
//Utils
const utils = inject('utils')
const route = useRoute()
// local state
const table = ref(null)
let isFetchingInitialData = ref(true)
let showFilters = ref(false)
const status = ref(['DRAFT', 'DUE', 'SENT', 'VIEWED', 'OVERDUE', 'COMPLETED'])
const filters = reactive({
status: '',
from_date: '',
to_date: '',
invoice_number: '',
})
// store
const invoiceStore = useInvoiceStore()
const globalStore = useGlobalStore()
// Invoice Table columns Data
const currency = computed(() => {
return globalStore.currency
})
const itemColumns = computed(() => {
return [
{
key: 'invoice_date',
label: t('invoices.date'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'invoice_number', label: t('invoices.number') },
{ key: 'status', label: t('invoices.status') },
{ key: 'paid_status', label: t('invoices.paid_status') },
{
key: 'due_amount',
label: t('dashboard.recent_invoices_card.amount_due'),
},
{
key: 'actions',
thClass: 'text-right',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
// computed props
const showEmptyScreen = computed(() => {
return !invoiceStore.totalInvoices && !isFetchingInitialData.value
})
//watch
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
//methods
function refreshTable() {
table.value.refresh()
}
function setFilters() {
refreshTable()
}
function clearFilter() {
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.invoice_number = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function fetchData({ page, sort }) {
let data = {
status: filters.status,
invoice_number: filters.invoice_number,
from_date: filters.from_date,
to_date: filters.to_date,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await invoiceStore.fetchInvoices(data, globalStore.companySlug)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
</script>

View File

@ -0,0 +1,405 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle.invoice_number">
<template #actions>
<BaseButton
:disabled="isSendingEmail"
variant="primary-outline"
class="mr-2"
tag="a"
:href="`/invoices/pdf/${invoice.unique_hash}`"
download
>
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
{{ $t('invoices.download') }}
</template>
</BaseButton>
<BaseButton
v-if="
invoiceStore?.selectedViewInvoice?.paid_status !== 'PAID' &&
globalStore.enabledModules.includes('Payments')
"
variant="primary"
@click="payInvoice"
>
{{ $t('invoices.pay_invoice') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 bg-white 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.invoice_number"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearch"
>
<template #right>
<BaseIcon name="SearchIcon" class="h-5 text-gray-400" />
</template>
</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" class="h-5" />
</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_date"
v-model="searchData.orderByField"
:label="$t('invoices.invoice_date')"
name="filter"
size="sm"
value="invoice_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_due_date"
v-model="searchData.orderByField"
:label="$t('invoices.due_date')"
name="filter"
size="sm"
value="due_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_invoice_number"
v-model="searchData.orderByField"
:label="$t('invoices.invoice_number')"
size="sm"
name="filter"
value="invoice_number"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" variant="white" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
sw-scroll
"
>
<router-link
v-for="(invoice, index) in invoiceStore.invoices"
:id="'invoice-' + invoice.id"
:key="index"
:to="`/${globalStore.companySlug}/customer/invoices/${invoice.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(invoice.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<div
class="
mb-1
not-italic
font-medium
leading-5
text-gray-500
capitalize
text-md
"
>
{{ invoice.invoice_number }}
</div>
<BaseInvoiceStatusBadge :status="invoice.status">
{{ invoice.status }}
</BaseInvoiceStatusBadge>
</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.currency"
/>
<div class="text-sm text-right text-gray-500 non-italic">
{{ invoice.formatted_invoice_date }}
</div>
</div>
</router-link>
<p
v-if="!invoiceStore.invoices.length"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('invoices.no_matching_invoices') }}
</p>
</div>
</div>
<!-- pdf -->
<div
class="flex flex-col min-h-0 mt-8 overflow-hidden"
style="height: 75vh"
>
<iframe
v-if="shareableLink"
ref="report"
:src="shareableLink"
class="flex-1 border border-gray-400 border-solid rounded-md"
@click="ViewReportsPDF"
/>
</div>
</BasePage>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import BaseDropdown from '@/scripts/components/base/BaseDropdown.vue'
import BaseDropdownItem from '@/scripts/components/base/BaseDropdownItem.vue'
import { debounce } from 'lodash'
import { ref, reactive, computed, inject, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useNotificationStore } from '@/scripts/stores/notification'
import moment from 'moment'
import { useInvoiceStore } from '@/scripts/customer/stores/invoice'
import { useGlobalStore } from '@/scripts/customer/stores/global'
// Router
const route = useRoute()
//store
const invoiceStore = useInvoiceStore()
const globalStore = useGlobalStore()
const { tm } = useI18n()
//local state
let invoice = reactive({})
let searchData = reactive({
orderBy: '',
orderByField: '',
invoice_number: '',
// searchText: '',
})
let url = ref(null)
let siteURL = ref(null)
let isSearching = ref(false)
let isSendingEmail = ref(false)
let isMarkingAsSent = ref(false)
//Utils
const utils = inject('utils')
//Store
const notificationStore = useNotificationStore()
// Computed Props
const pageTitle = computed(() => {
return invoiceStore.selectedViewInvoice
})
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() =>
getOrderBy.value ? tm('general.ascending') : tm('general.descending')
)
const shareableLink = computed(() => {
return invoice.unique_hash ? `/invoices/pdf/${invoice.unique_hash}` : false
})
// Watcher
watch(route, () => {
loadInvoice()
})
// Created
loadInvoices()
loadInvoice()
onSearch = debounce(onSearch, 500)
// Methods
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadInvoices() {
await invoiceStore.fetchInvoices(
{
limit: 'all',
},
globalStore.companySlug
)
setTimeout(() => {
scrollToInvoice()
}, 500)
}
async function loadInvoice() {
if (route && route.params.id) {
let response = await invoiceStore.fetchViewInvoice(
{
id: route.params.id,
},
globalStore.companySlug
)
if (response.data) {
Object.assign(invoice, response.data.data)
}
}
}
function scrollToInvoice() {
const el = document.getElementById(`invoice-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch() {
let data = {}
if (
searchData.invoice_number !== '' &&
searchData.invoice_number !== null &&
searchData.invoice_number !== undefined
) {
data.invoice_number = searchData.invoice_number
}
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 invoiceStore.searchInvoice(
data,
globalStore.companySlug
)
isSearching.value = false
if (response.data.data) {
invoiceStore.invoices = 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
}
function payInvoice() {
router.push({
name: 'invoice.portal.payment',
params: {
id: invoiceStore.selectedViewInvoice.id,
company: invoiceStore.selectedViewInvoice.company.slug,
},
})
}
</script>

View File

@ -0,0 +1,259 @@
<template>
<BasePage>
<BasePageHeader :title="$t('payments.title')">
<BaseBreadcrumb slot="breadcrumbs">
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${globalStore.companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem :title="$tc('payments.payment', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="paymentStore.totalPayments"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
:class="slotProps.class"
name="FilterIcon"
/>
<BaseIcon v-else :class="slotProps.class" name="XIcon" />
</template>
</BaseButton>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$t('payments.payment_number')" class="px-3">
<BaseInput
v-model="filters.payment_number"
:placeholder="$t('payments.payment_number')"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('payments.payment_mode')" class="px-3">
<BaseMultiselect
v-model="filters.payment_mode"
value-prop="id"
track-by="name"
:filter-results="false"
label="name"
resolve-on-load
:delay="100"
searchable
:options="searchPayment"
:placeholder="$t('payments.payment_mode')"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('payments.no_payments')"
:description="$t('payments.list_of_payments')"
>
<CapsuleIcon class="mt-5 mb-4" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="paymentColumns"
:placeholder-count="paymentStore.totalPayments >= 20 ? 10 : 5"
class="mt-10"
>
<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-payment_mode="{ row }">
<span>
{{
row.data.payment_method
? row.data.payment_method.name
: $t('payments.not_selected')
}}
</span>
</template>
<template #cell-invoice_number="{ row }">
<span>
{{
row.data.invoice?.invoice_number
? row.data.invoice?.invoice_number
: $t('payments.no_invoice')
}}
</span>
</template>
<template #cell-amount="{ row }">
<div v-html="utils.formatMoney(row.data.amount, currency)" />
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<BaseIcon name="DotsHorizontalIcon" class="w-5 text-gray-500" />
</template>
<router-link :to="`payments/${row.data.id}/view`">
<BaseDropdownItem>
<BaseIcon name="EyeIcon" class="h-5 mr-3 text-gray-600" />
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { debouncedWatch } from '@vueuse/core'
import BaseTable from '@/scripts/components/base/base-table/BaseTable.vue'
import CapsuleIcon from '@/scripts/components/icons/empty/CapsuleIcon.vue'
import { ref, reactive, inject, computed } from 'vue'
import BaseDropdownItem from '@/scripts/components/base/BaseDropdownItem.vue'
import BaseDropdown from '@/scripts/components/base/BaseDropdown.vue'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '@/scripts/customer/stores/payment'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useRoute } from 'vue-router'
const { tm, t } = useI18n()
let showFilters = ref(false)
let sortedBy = ref('created_at')
let isFetchingInitialData = ref(true)
let table = ref(null)
const filters = reactive({
payment_mode: '',
payment_number: '',
})
//Utils
const utils = inject('utils')
//Store
const route = useRoute()
const paymentStore = usePaymentStore()
const globalStore = useGlobalStore()
// Computed Props
const showEmptyScreen = computed(() => {
return !paymentStore.totalPayments && !isFetchingInitialData.value
})
const currency = computed(() => {
return globalStore.currency
})
// Payment Table columns Data
const paymentColumns = computed(() => {
return [
{
key: 'payment_date',
label: t('payments.date'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'payment_number', label: t('payments.payment_number') },
{ 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,
},
]
})
// Created
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
// Methods
async function searchPayment(search) {
let res = await paymentStore.fetchPaymentModes(
search,
globalStore.companySlug
)
return res.data.data
}
async function fetchData({ page, filter, sort }) {
let data = {
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, globalStore.companySlug)
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() {
table.value.refresh()
}
function setFilters() {
refreshTable()
}
function clearFilter() {
filters.customer = ''
filters.payment_mode = ''
filters.payment_number = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@ -0,0 +1,374 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle.payment_number">
<template #actions>
<BaseButton
:disabled="isSendingEmail"
variant="primary-outline"
tag="a"
download
:href="`/payments/pdf/${payment.unique_hash}`"
>
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
{{ $t('general.download') }}
</template>
</BaseButton>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 bg-white 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.payment_number"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearch"
>
<template #right>
<BaseIcon name="SearchIcon" class="h-5 text-gray-400" />
</template>
</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" class="h-5" />
</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="rounded-md pt-3 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="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_payment_date"
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="rounded-md pt-3 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" variant="white" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
sw-scroll
"
>
<router-link
v-for="(payment, index) in paymentStore.payments"
:id="'payment-' + payment.id"
:key="index"
:to="`/${globalStore.companySlug}/customer/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">
<div
class="
mb-1
text-md
not-italic
font-medium
leading-5
text-gray-500
capitalize
"
>
{{ payment.payment_number }}
</div>
</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="payment.amount"
:currency="payment.currency"
/>
<div class="text-sm text-right text-gray-500 non-italic">
{{ payment.formatted_payment_date }}
</div>
</div>
</router-link>
<p
v-if="!paymentStore.payments.length"
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 BaseDropdown from '@/scripts/components/base/BaseDropdown.vue'
import BaseDropdownItem from '@/scripts/components/base/BaseDropdownItem.vue'
import { debounce } from 'lodash'
import { ref, reactive, computed, inject, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useNotificationStore } from '@/scripts/stores/notification'
import moment from 'moment'
import { usePaymentStore } from '@/scripts/customer/stores/payment'
import { useGlobalStore } from '@/scripts/customer/stores/global'
// Router
const route = useRoute()
const paymentStore = usePaymentStore()
const globalStore = useGlobalStore()
const { tm, t } = useI18n()
// let id = ref(null)
let payment = reactive({})
let searchData = reactive({
orderBy: '',
orderByField: '',
payment_number: '',
})
let isSearching = ref(false)
let isSendingEmail = ref(false)
let isMarkingAsSent = ref(false)
//Utils
const $utils = inject('utils')
//Store
const notificationStore = useNotificationStore()
// Computed Props
const pageTitle = computed(() => {
return paymentStore.selectedViewPayment
})
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() =>
getOrderBy.value ? tm('general.ascending') : tm('general.descending')
)
const shareableLink = computed(() => {
return payment.unique_hash ? `/payments/pdf/${payment.unique_hash}` : false
})
// Watcher
watch(route, () => {
loadPayment()
})
// Created
loadPayments()
loadPayment()
onSearch = debounce(onSearch, 500)
// Methods
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadPayments() {
await paymentStore.fetchPayments(
{
limit: 'all',
},
globalStore.companySlug
)
setTimeout(() => {
scrollToPayment()
}, 500)
}
async function loadPayment() {
if (route && route.params.id) {
let response = await paymentStore.fetchViewPayment(
{
id: route.params.id,
},
globalStore.companySlug
)
if (response.data) {
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.payment_number !== '' &&
searchData.payment_number !== null &&
searchData.payment_number !== undefined
) {
data.payment_number = searchData.payment_number
}
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,
globalStore.companySlug
)
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
}
</script>

View File

@ -0,0 +1,282 @@
<template>
<form class="relative h-full mt-4" @submit.prevent="UpdateCustomerAddress">
<BaseCard>
<div class="mb-6">
<h6 class="font-bold text-left">
{{ $t('settings.menu_title.address_information') }}
</h6>
<p
class="mt-2 text-sm leading-snug text-left text-gray-500"
style="max-width: 680px"
>
{{ $t('settings.address_information.section_description') }}
</p>
</div>
<!-- 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>
<div
class="grid col-span-5 lg:col-span-4 gap-y-6 gap-x-4 md:grid-cols-6"
>
<BaseInputGroup
:label="$t('customers.name')"
class="w-full md:col-span-3"
>
<BaseInput
v-model.trim="userStore.userForm.billing.name"
type="text"
class="w-full"
name="address_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.country')"
class="md:col-span-3"
>
<BaseMultiselect
v-model="userStore.userForm.billing.country_id"
value-prop="id"
label="name"
track-by="name"
resolve-on-load
searchable
:options="globalStore.countries"
:placeholder="$t('general.select_country')"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.state')" class="md:col-span-3">
<BaseInput
v-model="userStore.userForm.billing.state"
name="billing.state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.city')" class="md:col-span-3">
<BaseInput
v-model="userStore.userForm.billing.city"
name="billing.city"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
class="md:col-span-3"
>
<BaseTextarea
v-model.trim="userStore.userForm.billing.address_street_1"
:placeholder="$t('general.street_1')"
type="text"
name="billing_street1"
:container-class="`mt-3`"
/>
<BaseTextarea
v-model.trim="userStore.userForm.billing.address_street_2"
:placeholder="$t('general.street_2')"
type="text"
class="mt-3"
name="billing_street2"
:container-class="`mt-3`"
/>
</BaseInputGroup>
<div class="md:col-span-3">
<BaseInputGroup :label="$t('customers.phone')" class="text-left">
<BaseInput
v-model.trim="userStore.userForm.billing.phone"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.zip_code')"
class="mt-2 text-left"
>
<BaseInput
v-model.trim="userStore.userForm.billing.zip"
type="text"
name="zip"
/>
</BaseInputGroup>
</div>
</div>
</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
ref="sameAddress"
type="button"
@click="userStore.copyAddress(true)"
>
<template #left="slotProps">
<BaseIcon name="DocumentDuplicateIcon" :class="slotProps.class" />
</template>
{{ $t('customers.copy_billing_address') }}
</BaseButton>
</div>
</div>
<!-- Shipping 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.shipping_address') }}
</h6>
<div
v-if="userStore.userForm.shipping"
class="grid col-span-5 lg:col-span-4 gap-y-6 gap-x-4 md:grid-cols-6"
>
<BaseInputGroup :label="$t('customers.name')" class="md:col-span-3">
<BaseInput
v-model.trim="userStore.userForm.shipping.name"
type="text"
name="address_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.country')"
class="md:col-span-3"
>
<BaseMultiselect
v-model="userStore.userForm.shipping.country_id"
value-prop="id"
label="name"
track-by="name"
resolve-on-load
searchable
:options="globalStore.countries"
:placeholder="$t('general.select_country')"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.state')" class="md:col-span-3">
<BaseInput
v-model="userStore.userForm.shipping.state"
name="shipping.state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.city')" class="md:col-span-3">
<BaseInput
v-model="userStore.userForm.shipping.city"
name="shipping.city"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
class="md:col-span-3"
>
<BaseTextarea
v-model.trim="userStore.userForm.shipping.address_street_1"
type="text"
:placeholder="$t('general.street_1')"
name="shipping_street1"
/>
<BaseTextarea
v-model.trim="userStore.userForm.shipping.address_street_2"
type="text"
:placeholder="$t('general.street_2')"
name="shipping_street2"
class="mt-3"
/>
</BaseInputGroup>
<div class="md:col-span-3">
<BaseInputGroup :label="$t('customers.phone')" class="text-left">
<BaseInput
v-model.trim="userStore.userForm.shipping.phone"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.zip_code')"
class="mt-2 text-left"
>
<BaseInput
v-model.trim="userStore.userForm.shipping.zip"
type="text"
name="zip"
/>
</BaseInputGroup>
</div>
</div>
</div>
<div class="flex items-center justify-end">
<BaseButton :loading="isSaving" :disabled="isSaving">
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="SaveIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</div>
</BaseCard>
</form>
</template>
<script setup>
import { useUserStore } from '@/scripts/customer/stores/user'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { ref } from 'vue'
//store
const userStore = useUserStore()
const route = useRoute()
const { tm, t } = useI18n()
const globalStore = useGlobalStore()
// local state
let isSaving = ref(false)
// created
globalStore.fetchCountries()
// methods
function UpdateCustomerAddress() {
isSaving.value = true
let data = userStore.userForm
userStore
.updateCurrentUser({
data,
message: tm('customers.address_updated_message'),
})
.then((res) => {
isSaving.value = false
})
.catch((err) => {
isSaving.value = false
})
}
</script>

View File

@ -0,0 +1,252 @@
<template>
<form class="relative h-full mt-4" @submit.prevent="updateCustomerData">
<BaseCard>
<div>
<h6 class="font-bold text-left">
{{ $t('settings.account_settings.account_settings') }}
</h6>
<p
class="mt-2 text-sm leading-snug text-left text-gray-500"
style="max-width: 680px"
>
{{ $t('settings.account_settings.section_description') }}
</p>
</div>
<div class="grid gap-6 sm:grid-col-1 md:grid-cols-2 mt-6">
<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$.userForm.name.$error && v$.userForm.name.$errors[0].$message
"
required
>
<BaseInput
v-model="userStore.userForm.name"
:invalid="v$.userForm.name.$error"
@input="v$.userForm.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$tc('settings.account_settings.email')"
:error="
v$.userForm.email.$error && v$.userForm.email.$errors[0].$message
"
required
>
<BaseInput
v-model="userStore.userForm.email"
:invalid="v$.userForm.email.$error"
@input="v$.userForm.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.userForm.password.$error &&
v$.userForm.password.$errors[0].$message
"
:label="$tc('settings.account_settings.password')"
>
<BaseInput
v-model="userStore.userForm.password"
:type="isShowPassword ? 'text' : 'password'"
:invalid="v$.userForm.password.$error"
@input="v$.userForm.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
:label="$tc('settings.account_settings.confirm_password')"
:error="
v$.userForm.confirm_password.$error &&
v$.userForm.confirm_password.$errors[0].$message
"
>
<BaseInput
v-model="userStore.userForm.confirm_password"
:type="isShowConfirmPassword ? 'text' : 'password'"
:invalid="v$.userForm.confirm_password.$error"
@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-6">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="SaveIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</template>
<script setup>
import { SaveIcon } from '@heroicons/vue/solid'
import { ref, computed } from 'vue'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useUserStore } from '@/scripts/customer/stores/user'
import { useI18n } from 'vue-i18n'
import {
helpers,
sameAs,
email,
required,
minLength,
} from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useRoute } from 'vue-router'
const userStore = useUserStore()
const globalStore = useGlobalStore()
const route = useRoute()
const { t, tm } = useI18n()
// Local State
let imgFiles = ref([])
let isSaving = ref(false)
let avatarFileBlob = ref(null)
let isShowPassword = ref(false)
let isShowConfirmPassword = ref(false)
if (userStore.userForm.avatar) {
imgFiles.value.push({
image: userStore.userForm.avatar,
})
}
// Validation
const rules = computed(() => {
return {
userForm: {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(userStore.userForm.password)
),
},
},
}
})
const v$ = useVuelidate(
rules,
computed(() => userStore)
)
// created
// methods
function onFileInputChange(fileName, file) {
avatarFileBlob.value = file
}
function onFileInputRemove() {
avatarFileBlob.value = null
}
function updateCustomerData() {
v$.value.userForm.$touch()
if (v$.value.userForm.$invalid) {
return true
}
isSaving.value = true
let data = new FormData()
data.append('name', userStore.userForm.name)
data.append('email', userStore.userForm.email)
if (
userStore.userForm.password != null &&
userStore.userForm.password !== undefined &&
userStore.userForm.password !== ''
) {
data.append('password', userStore.userForm.password)
}
if (avatarFileBlob.value) {
data.append('customer_avatar', avatarFileBlob.value)
}
userStore
.updateCurrentUser({
data,
message: tm('settings.account_settings.updated_message'),
})
.then((res) => {
if (res.data.data) {
isSaving.value = false
userStore.$patch((state) => {
state.userForm.password = ''
state.userForm.confirm_password = ''
})
}
})
.catch((error) => {
isSaving.value = false
})
}
</script>

View File

@ -0,0 +1,130 @@
<template>
<BasePage>
<BasePageHeader :title="$tc('settings.setting', 2)" class="pb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem
:title="$tc('settings.setting', 2)"
:to="`/${companySlug}/customer/settings/customer-profile`"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<aside class="pb-3 lg:col-span-3">
<nav class="space-y-1">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in menuItems"
:key="index"
:title="menuItem.title"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<component :is="menuItem.icon" class="h-5 w-6" />
</template>
</BaseListItem>
</BaseList>
</nav>
</aside>
</div>
<div class="flex">
<div class="hidden mt-1 xl:block min-w-[240px]">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in menuItems"
:key="index"
:title="menuItem.title"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<component :is="menuItem.icon" class="h-5 w-6" />
</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 BaseList from '@/scripts/components/list/BaseList.vue'
import BaseListItem from '@/scripts/components/list/BaseListItem.vue'
import { OfficeBuildingIcon, UserIcon } from '@heroicons/vue/outline'
import { useGlobalStore } from '@/scripts/customer/stores/global'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
//route
const { useRoute, useRouter } = window.VueRouter
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const companySlug = computed(() => {
return globalStore.companySlug
})
// local state
const { global } = window.i18n
let currentSetting = ref({})
let activeIndex = ref()
const menuItems = reactive([
{
link: `/${globalStore.companySlug}/customer/settings/customer-profile`,
title: t('settings.account_settings.account_settings'),
icon: UserIcon,
},
{
link: `/${globalStore.companySlug}/customer/settings/address-info`,
title: t('settings.menu_title.address_information'),
icon: OfficeBuildingIcon,
},
])
// watch
watchEffect(() => {
if (route.path === `/${globalStore.companySlug}/customer/settings`) {
router.push({ name: 'customer.profile' })
}
const menu = menuItems.find((item) => {
return item.link === route.path
})
currentSetting.value = { ...menu }
})
// computed props
const dropdownMenuItems = computed(() => {
return menuItems
})
// methods
function hasActiveUrl(url) {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting) {
return router.push(setting.link)
}
</script>