Phase 4a: Feature modules — layouts, auth, admin, dashboard,

customers, items, invoices, estimates, shared document form

77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout,
header, sidebar, company switcher), auth views (login, register,
forgot/reset password), admin feature (dashboard, companies, users,
settings with typed store), company features (dashboard with chart/
stats, customers CRUD, items CRUD, invoices CRUD with full store,
estimates CRUD with full store), and shared document form components
(items table, item row, totals, notes, tax popup, template select,
exchange rate converter, calculation composable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 06:30:00 +02:00
parent e43e515614
commit 774b2614f0
77 changed files with 14451 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<router-link :to="`/admin/administration/companies/${row.id}/edit`">
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
<script setup lang="ts">
import type { Company } from '../../../types/domain/company'
interface Props {
row: Company
table: { refresh: () => void } | null
loadData: (() => void) | null
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,71 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<router-link :to="`/admin/administration/users/${row.id}/edit`">
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<BaseDropdownItem
v-if="row.id !== userStore.currentUser?.id"
@click="onImpersonate"
>
<BaseIcon
name="ArrowRightEndOnRectangleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('administration.users.impersonate') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../../../stores/user.store'
import { useDialogStore } from '../../../stores/dialog.store'
import { useAdminStore } from '../stores/admin.store'
import type { User } from '../../../types/domain/user'
interface Props {
row: User
table: { refresh: () => void } | null
loadData: (() => void) | null
}
const props = defineProps<Props>()
const userStore = useUserStore()
const adminStore = useAdminStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
function onImpersonate(): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('administration.users.impersonate_confirm', {
name: props.row.name,
}),
yesLabel: t('administration.users.impersonate'),
noLabel: t('general.cancel'),
variant: 'danger',
size: 'lg',
hideNoButton: false,
})
.then((confirmed: boolean) => {
if (confirmed) {
adminStore.impersonateUser(props.row.id).then(() => {
window.location.href = '/admin/dashboard'
})
}
})
}
</script>

View File

@@ -0,0 +1,20 @@
export { adminRoutes } from './routes'
export { useAdminStore } from './stores/admin.store'
export type {
AdminDashboardData,
FetchCompaniesParams,
FetchUsersParams,
UpdateCompanyData,
UpdateUserData,
} from './stores/admin.store'
export { default as AdminDashboardView } from './views/AdminDashboardView.vue'
export { default as AdminCompaniesView } from './views/AdminCompaniesView.vue'
export { default as AdminCompanyEditView } from './views/AdminCompanyEditView.vue'
export { default as AdminUsersView } from './views/AdminUsersView.vue'
export { default as AdminUserEditView } from './views/AdminUserEditView.vue'
export { default as AdminSettingsView } from './views/AdminSettingsView.vue'
export { default as AdminCompanyDropdown } from './components/AdminCompanyDropdown.vue'
export { default as AdminUserDropdown } from './components/AdminUserDropdown.vue'

View File

@@ -0,0 +1,113 @@
import type { RouteRecordRaw } from 'vue-router'
const CompanyLayout = () => import('../../layouts/CompanyLayout.vue')
const AdminDashboardView = () => import('./views/AdminDashboardView.vue')
const AdminCompaniesView = () => import('./views/AdminCompaniesView.vue')
const AdminCompanyEditView = () => import('./views/AdminCompanyEditView.vue')
const AdminUsersView = () => import('./views/AdminUsersView.vue')
const AdminUserEditView = () => import('./views/AdminUserEditView.vue')
const AdminSettingsView = () => import('./views/AdminSettingsView.vue')
export const adminRoutes: RouteRecordRaw[] = [
{
path: '/admin/administration',
component: CompanyLayout,
meta: {
requiresAuth: true,
isSuperAdmin: true,
},
children: [
{
path: 'dashboard',
name: 'admin.dashboard',
component: AdminDashboardView,
meta: {
isSuperAdmin: true,
},
},
{
path: 'companies',
name: 'admin.companies.index',
component: AdminCompaniesView,
meta: {
isSuperAdmin: true,
},
},
{
path: 'companies/:id/edit',
name: 'admin.companies.edit',
component: AdminCompanyEditView,
meta: {
isSuperAdmin: true,
},
},
{
path: 'users',
name: 'admin.users.index',
component: AdminUsersView,
meta: {
isSuperAdmin: true,
},
},
{
path: 'users/:id/edit',
name: 'admin.users.edit',
component: AdminUserEditView,
meta: {
isSuperAdmin: true,
},
},
{
path: 'settings',
name: 'admin.settings',
component: AdminSettingsView,
meta: {
isSuperAdmin: true,
},
children: [
{
path: 'mail-configuration',
name: 'admin.settings.mail',
meta: {
isSuperAdmin: true,
},
// Loaded by settings sub-routes
component: { template: '<router-view />' },
},
{
path: 'pdf-generation',
name: 'admin.settings.pdf',
meta: {
isSuperAdmin: true,
},
component: { template: '<router-view />' },
},
{
path: 'backup',
name: 'admin.settings.backup',
meta: {
isSuperAdmin: true,
},
component: { template: '<router-view />' },
},
{
path: 'file-disk',
name: 'admin.settings.disk',
meta: {
isSuperAdmin: true,
},
component: { template: '<router-view />' },
},
{
path: 'update-app',
name: 'admin.settings.update',
meta: {
isSuperAdmin: true,
},
component: { template: '<router-view />' },
},
],
},
],
},
]

View File

@@ -0,0 +1,203 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
import * as ls from '../../../utils/local-storage'
import type { Company } from '../../../types/domain/company'
import type { User } from '../../../types/domain/user'
import type { PaginatedResponse } from '../../../types/api'
export interface AdminDashboardData {
app_version: string
php_version: string
database: {
driver: string
version: string
}
counts: {
companies: number
users: number
}
}
export interface FetchCompaniesParams {
search?: string
orderByField?: string
orderBy?: string
page?: number
}
export interface FetchUsersParams {
display_name?: string
email?: string
phone?: string
orderByField?: string
orderBy?: string
page?: number
}
export interface UpdateCompanyData {
name: string
owner_id: number
vat_id?: string
tax_id?: string
address?: {
address_street_1?: string
address_street_2?: string
country_id?: number | null
state?: string
city?: string
phone?: string
zip?: string
}
}
export interface UpdateUserData {
name: string
email: string
phone?: string
password?: string
}
export const useAdminStore = defineStore('admin', () => {
// State
const companies = ref<Company[]>([])
const totalCompanies = ref<number>(0)
const selectedCompany = ref<Company | null>(null)
const users = ref<User[]>([])
const totalUsers = ref<number>(0)
const dashboardData = ref<AdminDashboardData | null>(null)
// Actions
async function fetchDashboard(): Promise<AdminDashboardData> {
try {
const { data } = await client.get(API.SUPER_ADMIN_DASHBOARD)
dashboardData.value = data
return data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchCompanies(params: FetchCompaniesParams): Promise<PaginatedResponse<Company>> {
try {
const { data } = await client.get(API.SUPER_ADMIN_COMPANIES, { params })
companies.value = data.data
totalCompanies.value = data.meta?.total ?? data.data.length
return data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchCompany(id: number | string): Promise<{ data: Company }> {
try {
const { data } = await client.get(`${API.SUPER_ADMIN_COMPANIES}/${id}`)
selectedCompany.value = data.data
return data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateCompany(id: number | string, payload: UpdateCompanyData): Promise<void> {
try {
await client.put(`${API.SUPER_ADMIN_COMPANIES}/${id}`, payload)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'Company updated successfully.',
})
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchUsers(params: FetchUsersParams): Promise<PaginatedResponse<User>> {
try {
const { data } = await client.get(API.SUPER_ADMIN_USERS, { params })
users.value = data.data
totalUsers.value = data.meta?.total ?? data.data.length
return data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchUser(id: number | string): Promise<{ data: User }> {
try {
const { data } = await client.get(`${API.SUPER_ADMIN_USERS}/${id}`)
return data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateUser(id: number | string, payload: UpdateUserData): Promise<void> {
try {
await client.put(`${API.SUPER_ADMIN_USERS}/${id}`, payload)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'User updated successfully.',
})
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function impersonateUser(id: number): Promise<void> {
try {
const { data } = await client.post(`${API.SUPER_ADMIN_USERS}/${id}/impersonate`)
ls.set('admin.impersonating', 'true')
ls.set('auth.token', `Bearer ${data.token}`)
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function stopImpersonating(): Promise<void> {
try {
await client.post(API.SUPER_ADMIN_STOP_IMPERSONATING)
} catch (err: unknown) {
handleApiError(err)
} finally {
ls.remove('admin.impersonating')
ls.remove('auth.token')
}
}
return {
// State
companies,
totalCompanies,
selectedCompany,
users,
totalUsers,
dashboardData,
// Actions
fetchDashboard,
fetchCompanies,
fetchCompany,
updateCompany,
fetchUsers,
fetchUser,
updateUser,
impersonateUser,
stopImpersonating,
}
})

View File

@@ -0,0 +1,210 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.companies.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
class="flex-1 mt-2"
>
<BaseInput
v-model="filters.search"
type="text"
name="search"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.companies.no_companies')"
:description="$t('administration.companies.list_description')"
>
<BaseIcon name="BuildingOfficeIcon" class="mt-5 mb-4 h-16 w-16 text-subtle" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="companyTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.companies.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-owner="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.name }}</span>
<span v-else class="text-subtle">-</span>
</template>
<template #cell-owner_email="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.email }}</span>
<span v-else class="text-subtle">-</span>
</template>
<template #cell-actions="{ row }">
<AdminCompanyDropdown
:row="row.data"
:table="tableRef"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdminStore } from '../stores/admin.store'
import AdminCompanyDropdown from '../components/AdminCompanyDropdown.vue'
import type { Company } from '../../../types/domain/company'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface TableResult {
data: Company[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
const adminStore = useAdminStore()
const { t } = useI18n()
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const tableRef = ref<{ refresh: () => void } | null>(null)
const filters = reactive({
search: '',
})
const companyTableColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('administration.companies.company_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'owner',
label: t('administration.companies.owner'),
sortable: false,
},
{
key: 'owner_email',
label: t('general.email'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const showEmptyScreen = computed<boolean>(() => {
return !adminStore.totalCompanies && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable(): void {
tableRef.value?.refresh()
}
async function fetchData({ page, sort }: FetchParams): Promise<TableResult> {
const params = {
search: filters.search || '',
orderByField: sort.fieldName || 'name',
orderBy: sort.order || 'asc',
page,
}
isFetchingInitialData.value = true
const response = await adminStore.fetchCompanies(params)
isFetchingInitialData.value = false
return {
data: response.data,
pagination: {
totalPages: response.meta.last_page,
currentPage: page,
totalCount: response.meta.total,
limit: 10,
},
}
}
function clearFilter(): void {
filters.search = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,274 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.companies.edit_company')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="/admin/administration/companies"
/>
<BaseBreadcrumbItem
:title="$t('administration.companies.edit_company')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('administration.companies.owner')"
:error="v$.owner_id.$error && v$.owner_id.$errors[0].$message"
required
>
<BaseMultiselect
v-model="formData.owner_id"
label="name"
:invalid="v$.owner_id.$error"
:options="searchUsers"
value-prop="id"
:can-deselect="false"
:can-clear="false"
searchable
:filter-results="false"
:min-chars="0"
:resolve-on-load="true"
:delay="300"
object
track-by="name"
@select="onOwnerSelect"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model="formData.tax_id" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model="formData.vat_id" type="text" />
</BaseInputGroup>
</BaseInputGrid>
<BaseDivider class="my-6" />
<h3 class="text-lg font-medium text-heading mb-4">
{{ $t('administration.companies.address') }}
</h3>
<BaseInputGrid>
<BaseInputGroup :label="$t('settings.company_info.phone')">
<BaseInput v-model="formData.address.phone" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.country')">
<BaseMultiselect
v-model="formData.address.country_id"
label="name"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.state')">
<BaseInput v-model="formData.address.state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.city')">
<BaseInput v-model="formData.address.city" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.zip')">
<BaseInput v-model="formData.address.zip" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.address')">
<BaseTextarea
v-model="formData.address.address_street_1"
rows="2"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useGlobalStore } from '../../../stores/global.store'
import { useAdminStore } from '../stores/admin.store'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
interface OwnerOption {
id: number
name: string
email: string
}
interface CompanyFormData {
name: string
owner_id: OwnerOption | null
vat_id: string
tax_id: string
address: {
address_street_1: string
address_street_2: string
country_id: number | null
state: string
city: string
phone: string
zip: string
}
}
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const adminStore = useAdminStore()
const { t } = useI18n()
const isLoading = ref<boolean>(true)
const isSaving = ref<boolean>(false)
const formData = reactive<CompanyFormData>({
name: '',
owner_id: null,
vat_id: '',
tax_id: '',
address: {
address_street_1: '',
address_street_2: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
owner_id: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
await globalStore.fetchCountries()
const response = await adminStore.fetchCompany(route.params.id as string)
const company = response.data
formData.name = company.name
formData.vat_id = company.vat_id ?? ''
formData.tax_id = company.tax_id ?? ''
if (company.owner) {
formData.owner_id = {
id: company.owner.id,
name: company.owner.name,
email: company.owner.email,
}
}
if (company.address) {
formData.address.address_street_1 = company.address.address_street_1 ?? ''
formData.address.address_street_2 = company.address.address_street_2 ?? ''
formData.address.country_id = company.address.country_id
formData.address.state = company.address.state ?? ''
formData.address.city = company.address.city ?? ''
formData.address.phone = company.address.phone ?? ''
formData.address.zip = company.address.zip ?? ''
}
isLoading.value = false
})
async function searchUsers(query: string): Promise<OwnerOption[]> {
const { data } = await client.get(API.SEARCH, {
params: {
search: query,
type: 'User',
},
})
return (data.users as Array<{ id: number; name: string; email: string }>).map((user) => ({
id: user.id,
name: `${user.name} (${user.email})`,
email: user.email,
}))
}
function onOwnerSelect(option: OwnerOption): void {
formData.owner_id = option
}
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
await adminStore.updateCompany(route.params.id as string, {
name: formData.name,
owner_id: formData.owner_id?.id ?? 0,
vat_id: formData.vat_id,
tax_id: formData.tax_id,
address: formData.address,
})
isSaving.value = false
router.push({ name: 'admin.companies.index' })
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<BasePage>
<BasePageHeader :title="$t('navigation.dashboard')">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('navigation.administration')"
to="/admin/administration/dashboard"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div v-if="isLoading" class="flex justify-center py-16">
<BaseGlobalLoader />
</div>
<div v-else class="grid grid-cols-1 gap-6 mt-6 md:grid-cols-2 lg:grid-cols-3">
<!-- App Version -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="ServerIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('general.app_version') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.app_version }}
</p>
</BaseCard>
<!-- PHP Version -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="CodeBracketIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">PHP</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.php_version }}
</p>
</BaseCard>
<!-- Database -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="CircleStackIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('general.database') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.database?.driver?.toUpperCase() }}
</p>
<p class="text-sm text-muted mt-1">
{{ data.database?.version }}
</p>
</BaseCard>
<!-- Companies -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="BuildingOfficeIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('navigation.companies') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.counts?.companies }}
</p>
</BaseCard>
<!-- Users -->
<BaseCard>
<template #header>
<div class="flex items-center">
<BaseIcon name="UsersIcon" class="w-5 h-5 mr-2 text-subtle" />
<span class="font-medium text-body">{{ $t('navigation.all_users') }}</span>
</div>
</template>
<p class="text-2xl font-semibold text-heading">
{{ data.counts?.users }}
</p>
</BaseCard>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAdminStore } from '../stores/admin.store'
import type { AdminDashboardData } from '../stores/admin.store'
const adminStore = useAdminStore()
const isLoading = ref<boolean>(true)
const data = ref<Partial<AdminDashboardData>>({})
onMounted(async () => {
try {
data.value = await adminStore.fetchDashboard()
} finally {
isLoading.value = false
}
})
</script>

View File

@@ -0,0 +1,117 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.settings.title')" class="mb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.settings.title')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="menuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update:model-value="navigateToSetting"
/>
</div>
<div class="flex gap-8">
<div class="hidden mt-1 xl:block min-w-[240px] sticky top-20 self-start">
<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>
<BaseIcon :name="menuItem.icon" />
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter, RouterView } from 'vue-router'
import { useI18n } from 'vue-i18n'
interface SettingsMenuItem {
title: string
link: string
icon: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const currentSetting = ref<SettingsMenuItem | undefined>(undefined)
const menuItems = computed<SettingsMenuItem[]>(() => [
{
title: t('settings.mail.mail_config'),
link: '/admin/administration/settings/mail-configuration',
icon: 'EnvelopeIcon',
},
{
title: t('settings.menu_title.pdf_generation'),
link: '/admin/administration/settings/pdf-generation',
icon: 'DocumentIcon',
},
{
title: t('settings.menu_title.backup'),
link: '/admin/administration/settings/backup',
icon: 'CircleStackIcon',
},
{
title: t('settings.menu_title.file_disk'),
link: '/admin/administration/settings/file-disk',
icon: 'FolderIcon',
},
{
title: t('settings.menu_title.update_app'),
link: '/admin/administration/settings/update-app',
icon: 'ArrowPathIcon',
},
])
watchEffect(() => {
if (route.path === '/admin/administration/settings') {
router.push('/admin/administration/settings/mail-configuration')
}
const item = menuItems.value.find((item) => {
return route.path.indexOf(item.link) > -1
})
currentSetting.value = item
})
function hasActiveUrl(url: string): boolean {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting: SettingsMenuItem): void {
router.push(setting.link)
}
</script>

View File

@@ -0,0 +1,187 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.users.edit_user')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="/admin/administration/users"
/>
<BaseBreadcrumbItem
:title="$t('administration.users.edit_user')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('users.name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
required
>
<BaseInput
v-model="formData.email"
type="email"
:invalid="v$.email.$error"
@blur="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')">
<BaseInput v-model="formData.phone" type="text" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('users.password')"
:error="v$.password.$error && v$.password.$errors[0].$message"
>
<BaseInput
v-model="formData.password"
type="password"
:invalid="v$.password.$error"
autocomplete="new-password"
@blur="v$.password.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div v-if="userData" class="mt-6 text-sm text-muted">
<p>
<strong>{{ $t('administration.users.role') }}:</strong>
{{ userData.role }}
</p>
<p v-if="userData.companies && userData.companies.length" class="mt-1">
<strong>{{ $t('navigation.companies') }}:</strong>
{{ userData.companies.map((c) => c.name).join(', ') }}
</p>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { required, email, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useAdminStore } from '../stores/admin.store'
import type { User } from '../../../types/domain/user'
interface UserFormData {
name: string
email: string
phone: string
password: string
}
const route = useRoute()
const router = useRouter()
const adminStore = useAdminStore()
const { t } = useI18n()
const isLoading = ref<boolean>(true)
const isSaving = ref<boolean>(false)
const userData = ref<User | null>(null)
const formData = reactive<UserFormData>({
name: '',
email: '',
phone: '',
password: '',
})
const rules = computed(() => ({
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_min_length', { count: 8 }),
minLength(8)
),
},
}))
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
const response = await adminStore.fetchUser(route.params.id as string)
const user = response.data
userData.value = user
formData.name = user.name
formData.email = user.email
formData.phone = user.phone ?? ''
isLoading.value = false
})
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data: Record<string, string> = {
name: formData.name,
email: formData.email,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
await adminStore.updateUser(route.params.id as string, data)
isSaving.value = false
router.push({ name: 'admin.users.index' })
}
</script>

View File

@@ -0,0 +1,271 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.users.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('users.name')"
class="flex-1 mt-2 mr-4"
>
<BaseInput
v-model="filters.display_name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.email')" class="flex-1 mt-2 mr-4">
<BaseInput
v-model="filters.email"
type="text"
name="email"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')" class="flex-1 mt-2">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.users.no_users')"
:description="$t('administration.users.list_description')"
>
<BaseIcon name="UsersIcon" class="mt-5 mb-4 h-16 w-16 text-subtle" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="userTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.users.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-email="{ row }">
<span>{{ row.data.email }}</span>
</template>
<template #cell-role="{ row }">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getRoleBadgeClass(row.data.role)"
>
{{ row.data.role }}
</span>
</template>
<template #cell-companies="{ row }">
<div v-if="row.data.companies && row.data.companies.length" class="flex flex-wrap gap-1">
<span
v-for="company in row.data.companies.slice(0, 3)"
:key="company.id"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-tertiary text-body"
>
{{ company.name }}
</span>
<span
v-if="row.data.companies.length > 3"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-tertiary text-muted"
>
+{{ row.data.companies.length - 3 }}
</span>
</div>
<span v-else class="text-subtle">-</span>
</template>
<template #cell-actions="{ row }">
<AdminUserDropdown
:row="row.data"
:table="tableRef"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdminStore } from '../stores/admin.store'
import AdminUserDropdown from '../components/AdminUserDropdown.vue'
import type { User } from '../../../types/domain/user'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface TableResult {
data: User[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
const adminStore = useAdminStore()
const { t } = useI18n()
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const tableRef = ref<{ refresh: () => void } | null>(null)
const filters = reactive({
display_name: '',
email: '',
phone: '',
})
const userTableColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('users.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'email',
label: t('general.email'),
},
{
key: 'role',
label: t('administration.users.role'),
sortable: false,
},
{
key: 'companies',
label: t('navigation.companies'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const showEmptyScreen = computed<boolean>(() => {
return !adminStore.totalUsers && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable(): void {
tableRef.value?.refresh()
}
async function fetchData({ page, sort }: FetchParams): Promise<TableResult> {
const params = {
display_name: filters.display_name || '',
email: filters.email || '',
phone: filters.phone || '',
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await adminStore.fetchUsers(params)
isFetchingInitialData.value = false
return {
data: response.data,
pagination: {
totalPages: response.meta.last_page,
currentPage: page,
totalCount: response.meta.total,
limit: 10,
},
}
}
function getRoleBadgeClass(role: string | null): string {
switch (role) {
case 'super admin':
return 'bg-purple-100 text-purple-800'
case 'admin':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-surface-tertiary text-heading'
}
}
function clearFilter(): void {
filters.display_name = ''
filters.email = ''
filters.phone = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,6 @@
export { authRoutes } from './routes'
export { default as LoginView } from './views/LoginView.vue'
export { default as ForgotPasswordView } from './views/ForgotPasswordView.vue'
export { default as ResetPasswordView } from './views/ResetPasswordView.vue'
export { default as RegisterWithInvitationView } from './views/RegisterWithInvitationView.vue'

View File

@@ -0,0 +1,52 @@
import type { RouteRecordRaw } from 'vue-router'
const AuthLayout = () => import('../../layouts/AuthLayout.vue')
const LoginView = () => import('./views/LoginView.vue')
const ForgotPasswordView = () => import('./views/ForgotPasswordView.vue')
const ResetPasswordView = () => import('./views/ResetPasswordView.vue')
const RegisterWithInvitationView = () => import('./views/RegisterWithInvitationView.vue')
export const authRoutes: RouteRecordRaw[] = [
{
path: '/login',
component: AuthLayout,
children: [
{
path: '',
name: 'login',
component: LoginView,
meta: {
requiresAuth: false,
title: 'Login',
},
},
{
path: '/forgot-password',
name: 'forgot-password',
component: ForgotPasswordView,
meta: {
requiresAuth: false,
title: 'Forgot Password',
},
},
{
path: '/reset-password/:token',
name: 'reset-password',
component: ResetPasswordView,
meta: {
requiresAuth: false,
title: 'Reset Password',
},
},
],
},
{
path: '/register',
name: 'register-with-invitation',
component: RegisterWithInvitationView,
meta: {
requiresAuth: false,
title: 'Register',
},
},
]

View File

@@ -0,0 +1,101 @@
<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-body"
>
{{ $t('general.back_to_login') }}
</router-link>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { required, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../../stores/auth.store'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
interface ForgotPasswordForm {
email: string
}
const notificationStore = useNotificationStore()
const authStore = useAuthStore()
const { t } = useI18n()
const formData = reactive<ForgotPasswordForm>({
email: '',
})
const isSent = ref<boolean>(false)
const isLoading = ref<boolean>(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(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
try {
isLoading.value = true
await authStore.forgotPassword({ email: formData.email })
notificationStore.showNotification({
type: 'success',
message: 'Mail sent successfully',
})
isSent.value = true
} catch (err: unknown) {
handleApiError(err)
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<form id="loginForm" class="mt-12 text-left" @submit.prevent="onSubmit">
<BaseInputGroup
:error="v$.email.$error && v$.email.$errors[0].$message"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.email"
:invalid="v$.email.$error"
focus
type="email"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="v$.password.$error && v$.password.$errors[0].$message"
:label="$t('login.password')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.password"
:invalid="v$.password.$error"
:type="inputType"
name="password"
@input="v$.password.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted 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-body"
>
{{ $t('login.forgot_password') }}
</router-link>
</div>
</div>
<BaseButton :loading="isLoading" type="submit">
{{ $t('login.login') }}
</BaseButton>
</form>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
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 '../../../stores/auth.store'
import { useNotificationStore } from '../../../stores/notification.store'
declare global {
interface Window {
demo_mode?: boolean
}
}
const notificationStore = useNotificationStore()
const authStore = useAuthStore()
const { t } = useI18n()
const router = useRouter()
const isLoading = ref<boolean>(false)
const isShowPassword = ref<boolean>(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 inputType = computed<string>(() => {
return isShowPassword.value ? 'text' : 'password'
})
async function onSubmit(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isLoading.value = true
try {
await authStore.login(authStore.loginData)
router.push('/admin/dashboard')
notificationStore.showNotification({
type: 'success',
message: 'Logged in successfully.',
})
} catch {
isLoading.value = false
}
}
onMounted(() => {
if (window.demo_mode) {
authStore.loginData.email = 'demo@invoiceshelf.com'
authStore.loginData.password = 'demo'
}
})
</script>

View File

@@ -0,0 +1,235 @@
<template>
<div class="flex items-center justify-center min-h-screen bg-surface-secondary">
<div class="w-full max-w-md p-8">
<!-- Logo -->
<div class="mb-8 text-center">
<MainLogo
v-if="!loginPageLogo"
class="inline-block w-48 h-auto text-primary-500"
/>
<img
v-else
:src="loginPageLogo"
class="inline-block w-48 h-auto"
/>
</div>
<!-- Loading -->
<div v-if="isLoading" class="text-center">
<p class="text-muted">Loading invitation details...</p>
</div>
<!-- Invalid/Expired -->
<div v-else-if="error" class="text-center">
<BaseIcon
name="ExclamationCircleIcon"
class="w-16 h-16 mx-auto text-red-400 mb-4"
/>
<h1 class="text-xl font-semibold text-heading mb-2">
Invalid Invitation
</h1>
<p class="text-muted">{{ error }}</p>
<router-link to="/login" class="text-primary-500 mt-4 inline-block">
Go to Login
</router-link>
</div>
<!-- Registration Form -->
<div v-else>
<div class="text-center mb-6">
<h1 class="text-2xl font-semibold text-heading">
Create Your Account
</h1>
<p class="text-muted mt-2">
You've been invited to join
<strong>{{ invitationDetails.company_name }}</strong> as
<strong>{{ invitationDetails.role_name }}</strong>
</p>
</div>
<BaseCard class="p-6">
<form @submit.prevent="submitRegistration">
<div class="space-y-4">
<BaseInputGroup
label="Name"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="form.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup label="Email">
<BaseInput
v-model="form.email"
type="email"
disabled
/>
</BaseInputGroup>
<BaseInputGroup
label="Password"
:error="v$.password.$error && v$.password.$errors[0].$message"
required
>
<BaseInput
v-model="form.password"
type="password"
:invalid="v$.password.$error"
@input="v$.password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
label="Confirm Password"
:error="
v$.password_confirmation.$error &&
v$.password_confirmation.$errors[0].$message
"
required
>
<BaseInput
v-model="form.password_confirmation"
type="password"
:invalid="v$.password_confirmation.$error"
@input="v$.password_confirmation.$touch()"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSubmitting"
:disabled="isSubmitting"
class="w-full mt-6"
type="submit"
>
Create Account & Join
</BaseButton>
</form>
</BaseCard>
<div class="text-center mt-4">
<router-link to="/login" class="text-sm text-muted hover:text-primary-500">
Already have an account? Log in
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { helpers, required, minLength, sameAs } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { authService } from '../../../api/services/auth.service'
import * as ls from '../../../utils/local-storage'
import MainLogo from '../../../components/icons/MainLogo.vue'
interface InvitationDetailsData {
email: string
company_name: string
role_name: string
}
interface RegistrationForm {
name: string
email: string
password: string
password_confirmation: string
}
const route = useRoute()
const router = useRouter()
const loginPageLogo = computed<string | false>(() => {
return (window as Record<string, unknown>).login_page_logo as string || false
})
const isLoading = ref<boolean>(true)
const isSubmitting = ref<boolean>(false)
const error = ref<string | null>(null)
const invitationDetails = ref<InvitationDetailsData>({
email: '',
company_name: '',
role_name: '',
})
const form = reactive<RegistrationForm>({
name: '',
email: '',
password: '',
password_confirmation: '',
})
const rules = computed(() => ({
name: {
required: helpers.withMessage('Name is required', required),
},
password: {
required: helpers.withMessage('Password is required', required),
minLength: helpers.withMessage('Password must be at least 8 characters', minLength(8)),
},
password_confirmation: {
required: helpers.withMessage('Please confirm your password', required),
sameAs: helpers.withMessage('Passwords do not match', sameAs(form.password)),
},
}))
const v$ = useVuelidate(
rules,
computed(() => form)
)
const token = computed<string>(() => route.query.invitation as string)
onMounted(async () => {
if (!token.value) {
error.value = 'No invitation token provided.'
isLoading.value = false
return
}
try {
const response = await authService.getInvitationDetails(token.value)
const details = response.data
invitationDetails.value = {
email: details.email,
company_name: details.company_name,
role_name: details.invited_by,
}
form.email = details.email
} catch {
error.value = 'This invitation is invalid or has expired.'
} finally {
isLoading.value = false
}
})
async function submitRegistration(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSubmitting.value = true
try {
await authService.registerWithInvitation({
name: form.name,
email: form.email,
password: form.password,
password_confirmation: form.password_confirmation,
token: token.value,
})
router.push('/admin/dashboard')
} catch {
// Validation errors handled by http interceptor
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<form id="loginForm" @submit.prevent="onSubmit">
<BaseInputGroup
:error="errorEmail"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="formData.email"
:invalid="v$.email.$error"
focus
type="email"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="errorPassword"
:label="$t('login.password')"
class="mb-4"
required
>
<BaseInput
v-model="formData.password"
:invalid="v$.password.$error"
type="password"
name="password"
@input="v$.password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="errorConfirmPassword"
:label="$t('login.retype_password')"
class="mb-4"
required
>
<BaseInput
v-model="formData.password_confirmation"
:invalid="v$.password_confirmation.$error"
type="password"
name="password_confirmation"
@input="v$.password_confirmation.$touch()"
/>
</BaseInputGroup>
<BaseButton :loading="isLoading" type="submit" variant="primary">
{{ $t('login.reset_password') }}
</BaseButton>
</form>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import useVuelidate from '@vuelidate/core'
import { required, email, minLength, sameAs } from '@vuelidate/validators'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../../stores/auth.store'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
interface ResetPasswordForm {
email: string
password: string
password_confirmation: string
}
const notificationStore = useNotificationStore()
const authStore = useAuthStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const formData = reactive<ResetPasswordForm>({
email: '',
password: '',
password_confirmation: '',
})
const isLoading = ref<boolean>(false)
const rules = computed(() => ({
email: { required, email },
password: {
required,
minLength: minLength(8),
},
password_confirmation: {
sameAsPassword: sameAs(formData.password),
},
}))
const v$ = useVuelidate(rules, formData)
const errorEmail = computed<string>(() => {
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 ''
})
const errorPassword = computed<string>(() => {
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 ''
})
const errorConfirmPassword = computed<string>(() => {
if (!v$.value.password_confirmation.$error) {
return ''
}
if (v$.value.password_confirmation.sameAsPassword.$invalid) {
return t('validation.password_incorrect')
}
return ''
})
async function onSubmit(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
try {
isLoading.value = true
await authStore.resetPassword({
email: formData.email,
password: formData.password,
password_confirmation: formData.password_confirmation,
token: route.params.token as string,
})
notificationStore.showNotification({
type: 'success',
message: t('login.password_reset_successfully'),
})
router.push('/login')
} catch (err: unknown) {
handleApiError(err)
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCustomerStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { useUserStore } from '../../../../stores/user.store'
interface RowData {
id: number
[key: string]: unknown
}
interface Props {
row: RowData | null
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}
interface Emits {
(e: 'deleted'): void
}
const ABILITIES = {
EDIT_CUSTOMER: 'edit-customer',
VIEW_CUSTOMER: 'view-customer',
DELETE_CUSTOMER: 'delete-customer',
} as const
const props = withDefaults(defineProps<Props>(), {
row: null,
table: null,
loadData: null,
})
const emit = defineEmits<Emits>()
const customerStore = useCustomerStore()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
function removeCustomer(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('customers.confirm_delete', 1),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res: boolean) => {
if (res) {
customerStore.deleteCustomer({ ids: [id] }).then((response) => {
if (response.success) {
props.loadData?.()
}
})
}
})
}
</script>
<template>
<BaseDropdown :content-loading="customerStore.isFetchingViewData">
<template #activator>
<BaseButton v-if="route.name === 'customers.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Customer -->
<router-link
v-if="userStore.hasAbilities(ABILITIES.EDIT_CUSTOMER) && row"
:to="`/admin/customers/${row.id}/edit`"
>
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<!-- View Customer -->
<router-link
v-if="
route.name !== 'customers.view' &&
userStore.hasAbilities(ABILITIES.VIEW_CUSTOMER) &&
row
"
:to="`customers/${row.id}/view`"
>
<BaseDropdownItem>
<BaseIcon
name="EyeIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
<!-- Delete Customer -->
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_CUSTOMER) && row"
@click="removeCustomer(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,650 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
email,
url,
helpers,
requiredIf,
sameAs,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '../../../../stores/modal.store'
import { useCustomerStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useGlobalStore } from '../../../../stores/global.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import CopyInputField from '@/scripts/admin/components/CopyInputField.vue'
// These stores are needed for auto-selecting customer after creation
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
const recurringInvoiceStore = useRecurringInvoiceStore()
const modalStore = useModalStore()
const estimateStore = useEstimateStore()
const customerStore = useCustomerStore()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const invoiceStore = useInvoiceStore()
const notificationStore = useNotificationStore()
const isFetchingInitialData = ref<boolean>(false)
const { t } = useI18n()
const route = useRoute()
const isEdit = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const isShowPassword = ref<boolean>(false)
const isShowConfirmPassword = ref<boolean>(false)
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'CustomerModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
currency_id: {
required: helpers.withMessage(t('validation.required'), required),
},
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)
),
},
email: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(customerStore.currentCustomer.enable_portal === true)
),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
prefix: {
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
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 v$ = useVuelidate(
rules,
computed(() => customerStore.currentCustomer)
)
const getCustomerPortalUrl = computed<string>(() => {
return `${window.location.origin}/${companyStore.selectedCompany?.slug}/customer/login`
})
function copyAddress(): void {
customerStore.copyAddress()
}
async function setInitialData(): Promise<void> {
if (!customerStore.isEdit) {
customerStore.currentCustomer.currency_id =
companyStore.selectedCompanyCurrency?.id ?? null
}
}
async function submitCustomerData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid && customerStore.currentCustomer.email === '') {
notificationStore.showNotification({
type: 'error',
message: t('settings.notification.please_enter_email'),
})
}
if (v$.value.$invalid) {
return
}
isLoading.value = true
const data = {
...customerStore.currentCustomer,
}
try {
const action = customerStore.isEdit
? customerStore.updateCustomer
: customerStore.addCustomer
const response = await action(data)
if (response.data) {
isLoading.value = false
// Automatically select newly created customer
if (route.name === 'invoices.create' || route.name === 'invoices.edit') {
invoiceStore.selectCustomer(response.data.id)
}
if (route.name === 'estimates.create' || route.name === 'estimates.edit') {
estimateStore.selectCustomer(response.data.id)
}
if (
route.name === 'recurring-invoices.create' ||
route.name === 'recurring-invoices.edit'
) {
recurringInvoiceStore.selectCustomer(response.data.id)
}
closeCustomerModal()
}
} catch {
isLoading.value = false
}
}
function closeCustomerModal(): void {
modalStore.closeModal()
setTimeout(() => {
customerStore.resetCurrentCustomer()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeCustomerModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeCustomerModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitCustomerData">
<div class="px-6 pb-3">
<BaseTabGroup>
<BaseTab :title="$t('customers.basic_info')" class="!mt-2">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('customers.display_name')"
required
:error="v$.name.$error && v$.name.$errors[0].$message"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.name"
type="text"
name="name"
class="mt-1 md:mt-0"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.currencies.currency')"
required
:error="
v$.currency_id.$error && v$.currency_id.$errors[0].$message
"
>
<BaseMultiselect
v-model="customerStore.currentCustomer.currency_id"
:options="globalStore.currencies"
value-prop="id"
searchable
:placeholder="$t('customers.select_currency')"
:max-height="200"
class="mt-1 md:mt-0"
track-by="name"
:invalid="v$.currency_id.$error"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.primary_contact_name')">
<BaseInput
v-model="customerStore.currentCustomer.contact_name"
type="text"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('login.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.email"
type="text"
name="email"
class="mt-1 md:mt-0"
:invalid="v$.email.$error"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.prefix')"
:error="v$.prefix.$error && v$.prefix.$errors[0].$message"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.prefix"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.prefix.$error"
@input="v$.prefix.$touch()"
/>
</BaseInputGroup>
<BaseInputGrid>
<BaseInputGroup :label="$t('customers.phone')">
<BaseInput
v-model.trim="customerStore.currentCustomer.phone"
type="text"
name="phone"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.website')"
:error="v$.website.$error && v$.website.$errors[0].$message"
>
<BaseInput
v-model="customerStore.currentCustomer.website"
type="url"
class="mt-1 md:mt-0"
:invalid="v$.website.$error"
@input="v$.website.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseInputGroup :label="$t('customers.tax_id')">
<BaseInput
v-model="customerStore.currentCustomer.tax_id"
type="text"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
</BaseInputGrid>
</BaseTab>
<BaseTab :title="$t('customers.portal_access')">
<BaseInputGrid class="col-span-5 lg:col-span-4">
<div class="md:col-span-2">
<p class="text-sm text-muted">
{{ $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$.password.$error && v$.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$.password.$error"
@input="v$.password.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:error="
v$.confirm_password.$error &&
v$.confirm_password.$errors[0].$message
"
:content-loading="isFetchingInitialData"
:label="$t('customers.confirm_password')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.confirm_password"
:content-loading="isFetchingInitialData"
:type="isShowConfirmPassword ? 'text' : 'password'"
name="confirm_password"
:invalid="v$.confirm_password.$error"
@input="v$.confirm_password.$touch()"
>
<template #right>
<BaseIcon
:name="isShowConfirmPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
</BaseInputGrid>
</BaseTab>
<BaseTab :title="$t('customers.billing_address')" class="!mt-2">
<BaseInputGrid layout="one-column">
<BaseInputGroup :label="$t('customers.name')">
<BaseInput
v-model="customerStore.currentCustomer.billing.name"
type="text"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.country')">
<BaseMultiselect
v-model="customerStore.currentCustomer.billing.country_id"
:options="globalStore.countries"
searchable
:show-labels="false"
:placeholder="$t('general.select_country')"
:allow-empty="false"
track-by="name"
class="mt-1 md:mt-0"
label="name"
value-prop="id"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.state')">
<BaseInput
v-model="customerStore.currentCustomer.billing.state"
type="text"
name="billingState"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.city')">
<BaseInput
v-model="customerStore.currentCustomer.billing.city"
type="text"
name="billingCity"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
:error="
v$.billing.address_street_1.$error &&
v$.billing.address_street_1.$errors[0].$message
"
>
<BaseTextarea
v-model="
customerStore.currentCustomer.billing.address_street_1
"
:placeholder="$t('general.street_1')"
rows="2"
cols="50"
class="mt-1 md:mt-0"
:invalid="v$.billing.address_street_1.$error"
@input="v$.billing.address_street_1.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseInputGrid layout="one-column">
<BaseInputGroup
:error="
v$.billing.address_street_2.$error &&
v$.billing.address_street_2.$errors[0].$message
"
>
<BaseTextarea
v-model="
customerStore.currentCustomer.billing.address_street_2
"
:placeholder="$t('general.street_2')"
rows="2"
cols="50"
:invalid="v$.billing.address_street_2.$error"
@input="v$.billing.address_street_2.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.phone')">
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.phone"
type="text"
name="phone"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.zip_code')">
<BaseInput
v-model="customerStore.currentCustomer.billing.zip"
type="text"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
</BaseInputGrid>
</BaseTab>
<BaseTab :title="$t('customers.shipping_address')" class="!mt-2">
<div class="grid md:grid-cols-12">
<div class="flex justify-end col-span-12">
<BaseButton
variant="primary"
type="button"
size="xs"
@click="copyAddress"
>
{{ $t('customers.copy_billing_address') }}
</BaseButton>
</div>
</div>
<BaseInputGrid layout="one-column">
<BaseInputGroup :label="$t('customers.name')">
<BaseInput
v-model="customerStore.currentCustomer.shipping.name"
type="text"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.country')">
<BaseMultiselect
v-model="customerStore.currentCustomer.shipping.country_id"
:options="globalStore.countries"
:searchable="true"
:show-labels="false"
:allow-empty="false"
:placeholder="$t('general.select_country')"
track-by="name"
class="mt-1 md:mt-0"
label="name"
value-prop="id"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.state')">
<BaseInput
v-model="customerStore.currentCustomer.shipping.state"
type="text"
name="shippingState"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.city')">
<BaseInput
v-model="customerStore.currentCustomer.shipping.city"
type="text"
name="shippingCity"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
:error="
v$.shipping.address_street_1.$error &&
v$.shipping.address_street_1.$errors[0].$message
"
>
<BaseTextarea
v-model="
customerStore.currentCustomer.shipping.address_street_1
"
:placeholder="$t('general.street_1')"
rows="2"
cols="50"
class="mt-1 md:mt-0"
:invalid="v$.shipping.address_street_1.$error"
@input="v$.shipping.address_street_1.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseInputGrid layout="one-column">
<BaseInputGroup
:error="
v$.shipping.address_street_2.$error &&
v$.shipping.address_street_2.$errors[0].$message
"
>
<BaseTextarea
v-model="
customerStore.currentCustomer.shipping.address_street_2
"
:placeholder="$t('general.street_2')"
rows="2"
cols="50"
:invalid="v$.shipping.address_street_2.$error"
@input="v$.shipping.address_street_2.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.phone')">
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.phone"
type="text"
name="phone"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.zip_code')">
<BaseInput
v-model="customerStore.currentCustomer.shipping.zip"
type="text"
class="mt-1 md:mt-0"
/>
</BaseInputGroup>
</BaseInputGrid>
</BaseTab>
</BaseTabGroup>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
class="mr-3 text-sm"
type="button"
variant="primary-outline"
@click="closeCustomerModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton :loading="isLoading" variant="primary" type="submit">
<template #left="slotProps">
<BaseIcon
v-if="!isLoading"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,7 @@
export { useCustomerStore } from './store'
export type {
CustomerForm,
CustomerFormAddress,
CustomerViewData,
} from './store'
export { default as customerRoutes } from './routes'

View File

@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router'
const customerRoutes: RouteRecordRaw[] = [
{
path: 'customers',
name: 'customers.index',
component: () => import('./views/CustomerIndexView.vue'),
meta: {
ability: 'view-customer',
},
},
{
path: 'customers/create',
name: 'customers.create',
component: () => import('./views/CustomerCreateView.vue'),
meta: {
ability: 'create-customer',
},
},
{
path: 'customers/:id/edit',
name: 'customers.edit',
component: () => import('./views/CustomerCreateView.vue'),
meta: {
ability: 'edit-customer',
},
},
{
path: 'customers/:id/view',
name: 'customers.view',
component: () => import('./views/CustomerDetailView.vue'),
meta: {
ability: 'view-customer',
},
},
]
export default customerRoutes

View File

@@ -0,0 +1,335 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { customerService } from '../../../api/services/customer.service'
import type {
CustomerListParams,
CustomerListResponse,
CustomerStatsData,
} from '../../../api/services/customer.service'
import { useNotificationStore } from '../../../stores/notification.store'
import { useGlobalStore } from '../../../stores/global.store'
import { useCompanyStore } from '../../../stores/company.store'
import { handleApiError } from '../../../utils/error-handling'
import type { Customer, CreateCustomerPayload } from '../../../types/domain/customer'
import type { Address } from '../../../types/domain/user'
import type { ApiResponse, DeletePayload } from '../../../types/api'
export interface CustomerFormAddress {
name: string | null
phone: string | null
address_street_1: string | null
address_street_2: string | null
city: string | null
state: string | null
country_id: number | null
zip: string | null
type: string | null
}
export interface CustomerForm {
id?: number
name: string
contact_name: string
email: string
phone: string | null
password: string
confirm_password: string
currency_id: number | null
website: string | null
prefix?: string | null
tax_id?: string | null
billing: CustomerFormAddress
shipping: CustomerFormAddress
customFields: unknown[]
fields: unknown[]
enable_portal: boolean
password_added?: boolean
}
export interface CustomerViewData {
customer?: Customer
[key: string]: unknown
}
function createAddressStub(): CustomerFormAddress {
return {
name: null,
phone: null,
address_street_1: null,
address_street_2: null,
city: null,
state: null,
country_id: null,
zip: null,
type: null,
}
}
function createCustomerStub(): CustomerForm {
return {
name: '',
contact_name: '',
email: '',
phone: null,
password: '',
confirm_password: '',
currency_id: null,
website: null,
billing: createAddressStub(),
shipping: createAddressStub(),
customFields: [],
fields: [],
enable_portal: false,
}
}
export const useCustomerStore = defineStore('customer', () => {
// State
const customers = ref<Customer[]>([])
const totalCustomers = ref<number>(0)
const selectAllField = ref<boolean>(false)
const selectedCustomers = ref<number[]>([])
const selectedViewCustomer = ref<CustomerViewData>({})
const isFetchingInitialSettings = ref<boolean>(false)
const isFetchingViewData = ref<boolean>(false)
const currentCustomer = ref<CustomerForm>(createCustomerStub())
const editCustomer = ref<Customer | null>(null)
// Getters
const isEdit = computed<boolean>(() => !!currentCustomer.value.id)
// Actions
function resetCurrentCustomer(): void {
currentCustomer.value = createCustomerStub()
}
function copyAddress(fromBillingToShipping = true): void {
if (fromBillingToShipping) {
currentCustomer.value.shipping = {
...currentCustomer.value.billing,
type: 'shipping',
}
}
}
async function fetchCustomerInitialSettings(isEditMode: boolean): Promise<void> {
const route = useRoute()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
isFetchingInitialSettings.value = true
const editActions: Promise<unknown>[] = []
if (isEditMode) {
editActions.push(fetchCustomer(Number(route.params.id)))
} else {
currentCustomer.value.currency_id =
companyStore.selectedCompanyCurrency?.id ?? null
}
try {
await Promise.all([
globalStore.fetchCurrencies(),
globalStore.fetchCountries(),
...editActions,
])
isFetchingInitialSettings.value = false
} catch (err: unknown) {
isFetchingInitialSettings.value = false
handleApiError(err)
}
}
async function fetchCustomers(params?: CustomerListParams): Promise<CustomerListResponse> {
try {
const response = await customerService.list(params)
customers.value = response.data
totalCustomers.value = response.meta.customer_total_count
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchViewCustomer(params: { id: number }): Promise<ApiResponse<CustomerStatsData>> {
isFetchingViewData.value = true
try {
const response = await customerService.getStats(params.id, params as Record<string, unknown>)
selectedViewCustomer.value = {}
Object.assign(selectedViewCustomer.value, response.data)
setAddressStub(response.data as unknown as Record<string, unknown>)
isFetchingViewData.value = false
return response
} catch (err: unknown) {
isFetchingViewData.value = false
handleApiError(err)
throw err
}
}
async function fetchCustomer(id: number): Promise<ApiResponse<Customer>> {
try {
const response = await customerService.get(id)
Object.assign(currentCustomer.value, response.data)
setAddressStub(response.data as unknown as Record<string, unknown>)
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function addCustomer(data: CustomerForm): Promise<ApiResponse<Customer>> {
try {
const response = await customerService.create(data as unknown as CreateCustomerPayload)
customers.value.push(response.data)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'customers.created_message',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateCustomer(data: CustomerForm): Promise<ApiResponse<Customer>> {
try {
const response = await customerService.update(
data.id!,
data as unknown as Partial<CreateCustomerPayload>
)
if (response.data) {
const pos = customers.value.findIndex(
(customer) => customer.id === response.data.id
)
if (pos !== -1) {
customers.value[pos] = response.data
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'customers.updated_message',
})
}
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteCustomer(payload: DeletePayload): Promise<{ success: boolean }> {
try {
const response = await customerService.delete(payload)
const index = customers.value.findIndex(
(customer) => customer.id === payload.ids[0]
)
if (index !== -1) {
customers.value.splice(index, 1)
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'customers.deleted_message',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteMultipleCustomers(): Promise<{ success: boolean }> {
try {
const response = await customerService.delete({ ids: selectedCustomers.value })
selectedCustomers.value.forEach((customerId) => {
const index = customers.value.findIndex(
(_customer) => _customer.id === customerId
)
if (index !== -1) {
customers.value.splice(index, 1)
}
})
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'customers.deleted_message',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
function setSelectAllState(data: boolean): void {
selectAllField.value = data
}
function selectCustomer(data: number[]): void {
selectedCustomers.value = data
selectAllField.value = selectedCustomers.value.length === customers.value.length
}
function selectAllCustomers(): void {
if (selectedCustomers.value.length === customers.value.length) {
selectedCustomers.value = []
selectAllField.value = false
} else {
selectedCustomers.value = customers.value.map((customer) => customer.id)
selectAllField.value = true
}
}
function setAddressStub(data: Record<string, unknown>): void {
if (!data.billing) {
currentCustomer.value.billing = createAddressStub()
}
if (!data.shipping) {
currentCustomer.value.shipping = createAddressStub()
}
}
return {
customers,
totalCustomers,
selectAllField,
selectedCustomers,
selectedViewCustomer,
isFetchingInitialSettings,
isFetchingViewData,
currentCustomer,
editCustomer,
isEdit,
resetCurrentCustomer,
copyAddress,
fetchCustomerInitialSettings,
fetchCustomers,
fetchViewCustomer,
fetchCustomer,
addCustomer,
updateCustomer,
deleteCustomer,
deleteMultipleCustomers,
setSelectAllState,
selectCustomer,
selectAllCustomers,
setAddressStub,
}
})

View File

@@ -0,0 +1,739 @@
<script setup lang="ts">
import { computed, 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 '../store'
import { useGlobalStore } from '../../../../stores/global.store'
import { useCompanyStore } from '../../../../stores/company.store'
import CustomerCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import CopyInputField from '@/scripts/admin/components/CopyInputField.vue'
// Custom field store - imported from original location
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
const customerStore = useCustomerStore()
const customFieldStore = useCustomFieldStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const customFieldValidationScope = 'customFields'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const isFetchingInitialData = ref<boolean>(false)
const isShowPassword = ref<boolean>(false)
const isShowConfirmPassword = ref<boolean>(false)
const isSaving = ref<boolean>(false)
const isEdit = computed<boolean>(() => route.name === 'customers.edit')
const isLoadingContent = computed<boolean>(
() => customerStore.isFetchingInitialSettings
)
const pageTitle = computed<string>(() =>
isEdit.value ? t('customers.edit_customer') : t('customers.new_customer')
)
const rules = computed(() => ({
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<string>(() => {
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(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data = {
...customerStore.currentCustomer,
}
try {
const action = isEdit.value
? customerStore.updateCustomer
: customerStore.addCustomer
const response = await action(data)
router.push(`/admin/customers/${response.data.id}/view`)
} catch {
isSaving.value = false
}
}
</script>
<template>
<BasePage>
<form @submit.prevent="submitCustomerData">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('customers.customer', 2)"
to="/admin/customers"
/>
<BaseBreadcrumbItem :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="ArrowDownOnSquareIcon" :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"
: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"
/>
</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"
:invalid="v$.currentCustomer.prefix.$error"
@input="v$.currentCustomer.prefix.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.tax_id')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.tax_id"
:content-loading="isFetchingInitialData"
type="text"
name="tax_id"
/>
</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-muted">
{{ $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
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted 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="$t('customers.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
:name="isShowConfirmPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted 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>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '../store'
import { useUserStore } from '../../../../stores/user.store'
import CustomerDropdown from '../components/CustomerDropdown.vue'
import CustomerViewSidebar from '@/scripts/admin/views/customers/partials/CustomerViewSidebar.vue'
import CustomerChart from '@/scripts/admin/views/customers/partials/CustomerChart.vue'
const ABILITIES = {
EDIT_CUSTOMER: 'edit-customer',
DELETE_CUSTOMER: 'delete-customer',
CREATE_ESTIMATE: 'create-estimate',
CREATE_INVOICE: 'create-invoice',
CREATE_PAYMENT: 'create-payment',
CREATE_EXPENSE: 'create-expense',
} as const
const customerStore = useCustomerStore()
const userStore = useUserStore()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const pageTitle = computed<string>(() => {
return customerStore.selectedViewCustomer.customer
? (customerStore.selectedViewCustomer.customer as { name: string }).name
: ''
})
const isLoading = computed<boolean>(() => customerStore.isFetchingViewData)
function canCreateTransaction(): boolean {
return userStore.hasAbilities([
ABILITIES.CREATE_ESTIMATE,
ABILITIES.CREATE_INVOICE,
ABILITIES.CREATE_PAYMENT,
ABILITIES.CREATE_EXPENSE,
])
}
function hasAtleastOneAbility(): boolean {
return userStore.hasAbilities([
ABILITIES.DELETE_CUSTOMER,
ABILITIES.EDIT_CUSTOMER,
])
}
function refreshData(): void {
router.push('/admin/customers')
}
</script>
<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>
<BaseIcon name="DocumentIcon" class="mr-3 text-body" />
{{ $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-body" />
{{ $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-body" />
{{ $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-body" />
{{ $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>

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { debouncedWatch } from '@vueuse/core'
import { reactive, ref, computed, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import CustomerDropdown from '../components/CustomerDropdown.vue'
import AstronautIcon from '@/scripts/components/icons/empty/AstronautIcon.vue'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface FetchResult {
data: unknown[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
interface CustomerFilters {
display_name: string
contact_name: string
phone: string
}
const ABILITIES = {
CREATE_CUSTOMER: 'create-customer',
DELETE_CUSTOMER: 'delete-customer',
EDIT_CUSTOMER: 'edit-customer',
VIEW_CUSTOMER: 'view-customer',
} as const
const companyStore = useCompanyStore()
const dialogStore = useDialogStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
const tableComponent = ref<{ refresh: () => void } | null>(null)
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const { t } = useI18n()
const filters = reactive<CustomerFilters>({
display_name: '',
contact_name: '',
phone: '',
})
const showEmptyScreen = computed<boolean>(
() => !customerStore.totalCustomers && !isFetchingInitialData.value
)
const selectField = computed<number[]>({
get: () => customerStore.selectedCustomers,
set: (value: number[]) => {
customerStore.selectCustomer(value)
},
})
const selectAllFieldStatus = computed<boolean>({
get: () => customerStore.selectAllField,
set: (value: boolean) => {
customerStore.setSelectAllState(value)
},
})
const customerColumns = computed<TableColumn[]>(() => [
{
key: 'status',
thClass: 'extra w-10 pr-0',
sortable: false,
tdClass: 'font-medium text-heading pr-0',
},
{
key: 'name',
label: t('customers.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ 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(): void {
tableComponent.value?.refresh()
}
function setFilters(): void {
refreshTable()
}
function hasAtleastOneAbility(): boolean {
return userStore.hasAbilities([
ABILITIES.DELETE_CUSTOMER,
ABILITIES.EDIT_CUSTOMER,
ABILITIES.VIEW_CUSTOMER,
])
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const 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
const response = await customerStore.fetchCustomers(data)
isFetchingInitialData.value = false
return {
data: response.data,
pagination: {
totalPages: response.meta.last_page,
currentPage: page,
totalCount: response.meta.total,
limit: 10,
},
}
}
function clearFilter(): void {
filters.display_name = ''
filters.contact_name = ''
filters.phone = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function removeMultipleCustomers(): void {
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: boolean) => {
if (res) {
customerStore.deleteMultipleCustomers().then((response) => {
if (response) {
refreshTable()
}
})
}
})
}
</script>
<template>
<BasePage>
<!-- Page Header Section -->
<BasePageHeader :title="$t('customers.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('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="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :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-body" />
{{ $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"
tag="span"
class="font-medium text-primary-500 flex flex-col"
/>
<BaseText
:text="row.data.contact_name ? row.data.contact_name : ''"
tag="span"
class="text-xs text-subtle"
/>
</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>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDashboardStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import LineChart from '@/scripts/admin/components/charts/LineChart.vue'
import type { DashboardParams } from '../../../../api/services/dashboard.service'
interface YearOption {
label: string
value: string
}
const ABILITY_DASHBOARD = 'dashboard'
const dashboardStore = useDashboardStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
const { t } = useI18n()
const years = ref<YearOption[]>([
{ label: t('dateRange.this_year'), value: 'This year' },
{ label: t('dateRange.previous_year'), value: 'Previous year' },
])
const selectedYear = ref<string>('This year')
watch(
selectedYear,
(val: string) => {
if (val === 'Previous year') {
loadData({ previous_year: 1 })
} else {
loadData()
}
},
{ immediate: true }
)
async function loadData(params?: DashboardParams): Promise<void> {
if (userStore.hasAbilities(ABILITY_DASHBOARD)) {
await dashboardStore.loadData(params)
}
}
</script>
<template>
<div>
<div
v-if="dashboardStore.isDashboardDataLoaded"
class="grid grid-cols-10 mt-8 bg-surface rounded-xl shadow border border-line-light"
>
<!-- 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="ChartBarSquareIcon" 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-line-default 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-line-default 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>
<!-- Chart Placeholder -->
<BaseContentPlaceholders
v-else
class="grid grid-cols-10 mt-8 bg-surface rounded shadow"
>
<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>
<div
class="
grid grid-cols-3
col-span-10
text-center
border-t border-l border-line-default 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-line-default 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>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
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 DashboardStatsItem from './DashboardStatsItem.vue'
import { useDashboardStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
const ABILITIES = {
VIEW_INVOICE: 'view-invoice',
VIEW_CUSTOMER: 'view-customer',
VIEW_ESTIMATE: 'view-estimate',
} as const
const dashboardStore = useDashboardStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
</script>
<template>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-9 xl:gap-8">
<!-- Amount Due -->
<DashboardStatsItem
v-if="userStore.hasAbilities(ABILITIES.VIEW_INVOICE)"
:icon-component="DollarIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
route="/admin/invoices"
:large="true"
:label="$t('dashboard.cards.due_amount')"
>
<BaseFormatMoney
:amount="dashboardStore.stats.totalAmountDue"
:currency="companyStore.selectedCompanyCurrency"
/>
</DashboardStatsItem>
<!-- Customers -->
<DashboardStatsItem
v-if="userStore.hasAbilities(ABILITIES.VIEW_CUSTOMER)"
:icon-component="CustomerIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
route="/admin/customers"
:label="
dashboardStore.stats.totalCustomerCount <= 1
? $t('dashboard.cards.customers', 1)
: $t('dashboard.cards.customers', 2)
"
>
{{ dashboardStore.stats.totalCustomerCount }}
</DashboardStatsItem>
<!-- Invoices -->
<DashboardStatsItem
v-if="userStore.hasAbilities(ABILITIES.VIEW_INVOICE)"
:icon-component="InvoiceIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
route="/admin/invoices"
:label="
dashboardStore.stats.totalInvoiceCount <= 1
? $t('dashboard.cards.invoices', 1)
: $t('dashboard.cards.invoices', 2)
"
>
{{ dashboardStore.stats.totalInvoiceCount }}
</DashboardStatsItem>
<!-- Estimates -->
<DashboardStatsItem
v-if="userStore.hasAbilities(ABILITIES.VIEW_ESTIMATE)"
:icon-component="EstimateIcon"
:loading="!dashboardStore.isDashboardDataLoaded"
route="/admin/estimates"
:label="
dashboardStore.stats.totalEstimateCount <= 1
? $t('dashboard.cards.estimates', 1)
: $t('dashboard.cards.estimates', 2)
"
>
{{ dashboardStore.stats.totalEstimateCount }}
</DashboardStatsItem>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { Component } from 'vue'
interface Props {
iconComponent: Component
loading?: boolean
route: string
label: string
large?: boolean
}
withDefaults(defineProps<Props>(), {
loading: false,
large: false,
})
</script>
<template>
<router-link
v-if="!loading"
class="
relative
flex
justify-between
p-5
bg-surface
rounded-xl
shadow
border border-line-light
hover:shadow-md
transition-shadow
xl:p-6
lg:col-span-2
"
:class="{ 'lg:!col-span-3': large }"
:to="route"
>
<div>
<span class="text-2xl font-bold leading-tight text-heading xl:text-3xl">
<slot />
</span>
<span class="block mt-1 text-sm font-medium leading-tight text-muted xl:text-base">
{{ 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>
<!-- Large placeholder -->
<BaseContentPlaceholders
v-else-if="large"
:rounded="true"
class="relative flex justify-between w-full p-3 bg-surface 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>
<!-- Small placeholder -->
<BaseContentPlaceholders
v-else
:rounded="true"
class="
relative
flex
justify-between
w-full
p-3
bg-surface
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,198 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDashboardStore } from '../store'
import { useUserStore } from '../../../../stores/user.store'
import InvoiceDropdown from '@/scripts/admin/components/dropdowns/InvoiceIndexDropdown.vue'
import EstimateDropdown from '@/scripts/admin/components/dropdowns/EstimateIndexDropdown.vue'
interface TableColumn {
key: string
label?: string
tdClass?: string
thClass?: string
sortable?: boolean
}
const ABILITIES = {
VIEW_INVOICE: 'view-invoice',
VIEW_ESTIMATE: 'view-estimate',
DELETE_INVOICE: 'delete-invoice',
EDIT_INVOICE: 'edit-invoice',
SEND_INVOICE: 'send-invoice',
CREATE_ESTIMATE: 'create-estimate',
EDIT_ESTIMATE: 'edit-estimate',
SEND_ESTIMATE: 'send-estimate',
} as const
const dashboardStore = useDashboardStore()
const { t } = useI18n()
const userStore = useUserStore()
const invoiceTableComponent = ref<InstanceType<typeof Object> | null>(null)
const estimateTableComponent = ref<InstanceType<typeof Object> | null>(null)
const dueInvoiceColumns = computed<TableColumn[]>(() => [
{
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<TableColumn[]>(() => [
{
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(): boolean {
return userStore.hasAbilities([
ABILITIES.DELETE_INVOICE,
ABILITIES.EDIT_INVOICE,
ABILITIES.VIEW_INVOICE,
ABILITIES.SEND_INVOICE,
])
}
function hasAtleastOneEstimateAbility(): boolean {
return userStore.hasAbilities([
ABILITIES.CREATE_ESTIMATE,
ABILITIES.EDIT_ESTIMATE,
ABILITIES.VIEW_ESTIMATE,
ABILITIES.SEND_ESTIMATE,
])
}
</script>
<template>
<div>
<div class="grid grid-cols-1 gap-6 mt-10 xl:grid-cols-2">
<!-- Due Invoices -->
<div
v-if="userStore.hasAbilities(ABILITIES.VIEW_INVOICE)"
class="due-invoices"
>
<div class="relative z-10 flex items-center justify-between mb-3">
<h6 class="mb-0 text-lg font-semibold leading-normal text-heading">
{{ $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>
<template
v-if="hasAtleastOneInvoiceAbility()"
#cell-actions="{ row }"
>
<InvoiceDropdown :row="row.data" :table="invoiceTableComponent" />
</template>
</BaseTable>
</div>
<!-- Recent Estimates -->
<div
v-if="userStore.hasAbilities(ABILITIES.VIEW_ESTIMATE)"
class="recent-estimates"
>
<div class="relative z-10 flex items-center justify-between mb-3">
<h6 class="mb-0 text-lg font-semibold leading-normal text-heading">
{{ $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.data" :table="estimateTableComponent" />
</template>
</BaseTable>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,8 @@
export { useDashboardStore } from './store'
export type {
DashboardStats,
DashboardChartData,
DueInvoice,
RecentEstimate,
} from './store'
export { default as dashboardRoutes } from './routes'

View File

@@ -0,0 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
const dashboardRoutes: RouteRecordRaw[] = [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('./views/DashboardView.vue'),
meta: {
ability: 'dashboard',
},
},
]
export default dashboardRoutes

View File

@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { dashboardService } from '../../../api/services/dashboard.service'
import type { DashboardParams, DashboardResponse, ChartData } from '../../../api/services/dashboard.service'
import type { Invoice } from '../../../types/domain/invoice'
import type { Estimate } from '../../../types/domain/estimate'
import { handleApiError } from '../../../utils/error-handling'
export interface DashboardStats {
totalAmountDue: number
totalCustomerCount: number
totalInvoiceCount: number
totalEstimateCount: number
}
export interface DashboardChartData {
months: string[]
invoiceTotals: number[]
expenseTotals: number[]
receiptTotals: number[]
netIncomeTotals: number[]
}
export interface DueInvoice {
id: number
invoice_number: string
due_amount: number
formattedDueDate: string
formatted_due_date: string
customer: {
id: number
name: string
currency?: {
id: number
code: string
symbol: string
}
}
}
export interface RecentEstimate {
id: number
estimate_number: string
total: number
status: string
formattedEstimateDate: string
formatted_estimate_date: string
customer: {
id: number
name: string
currency?: {
id: number
code: string
symbol: string
}
}
}
export const useDashboardStore = defineStore('dashboard', () => {
// State
const stats = ref<DashboardStats>({
totalAmountDue: 0,
totalCustomerCount: 0,
totalInvoiceCount: 0,
totalEstimateCount: 0,
})
const chartData = ref<DashboardChartData>({
months: [],
invoiceTotals: [],
expenseTotals: [],
receiptTotals: [],
netIncomeTotals: [],
})
const totalSales = ref<number>(0)
const totalReceipts = ref<number>(0)
const totalExpenses = ref<number>(0)
const totalNetIncome = ref<number>(0)
const recentDueInvoices = ref<DueInvoice[]>([])
const recentEstimates = ref<RecentEstimate[]>([])
const isDashboardDataLoaded = ref<boolean>(false)
// Actions
async function loadData(params?: DashboardParams): Promise<DashboardResponse> {
try {
const response = await dashboardService.load(params)
// Stats
stats.value.totalAmountDue = response.total_amount_due
stats.value.totalCustomerCount = response.total_customer_count
stats.value.totalInvoiceCount = response.total_invoice_count
stats.value.totalEstimateCount = response.total_estimate_count
// Chart Data
if (response.chart_data) {
chartData.value.months = response.chart_data.months
chartData.value.invoiceTotals = response.chart_data.invoice_totals
chartData.value.expenseTotals = response.chart_data.expense_totals
chartData.value.receiptTotals = response.chart_data.receipt_totals
chartData.value.netIncomeTotals = response.chart_data.net_income_totals
}
// Chart Labels
totalSales.value = Number(response.total_sales) || 0
totalReceipts.value = Number(response.total_receipts) || 0
totalExpenses.value = Number(response.total_expenses) || 0
totalNetIncome.value = Number(response.total_net_income) || 0
// Table Data
recentDueInvoices.value = response.recent_due_invoices as unknown as DueInvoice[]
recentEstimates.value = response.recent_estimates as unknown as RecentEstimate[]
isDashboardDataLoaded.value = true
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
return {
stats,
chartData,
totalSales,
totalReceipts,
totalExpenses,
totalNetIncome,
recentDueInvoices,
recentEstimates,
isDashboardDataLoaded,
loadData,
}
})

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../../../../stores/user.store'
import DashboardStats from '../components/DashboardStats.vue'
import DashboardChart from '../components/DashboardChart.vue'
import DashboardTable from '../components/DashboardTable.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
onMounted(() => {
const meta = route.meta as { ability?: string; isOwner?: boolean }
if (meta.ability && !userStore.hasAbilities(meta.ability)) {
router.push({ name: 'account.settings' })
} else if (meta.isOwner && !userStore.isOwner) {
router.push({ name: 'account.settings' })
}
})
</script>
<template>
<BasePage>
<DashboardStats />
<DashboardChart />
<DashboardTable />
</BasePage>
</template>

View File

@@ -0,0 +1,88 @@
<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 rounded-xl shadow border border-line-light bg-surface p-5"
>
<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"
/>
</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 lang="ts">
import { ExchangeRateConverter } from '../../../shared/document-form'
import { useEstimateStore } from '../store'
interface ValidationField {
$error: boolean
$errors: Array<{ $message: string }>
$touch: () => void
}
interface Props {
v: Record<string, ValidationField>
isLoading?: boolean
isEdit?: boolean
}
withDefaults(defineProps<Props>(), {
isLoading: false,
isEdit: false,
})
const estimateStore = useEstimateStore()
</script>

View File

@@ -0,0 +1,263 @@
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="isDetailView" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="text-white" />
</BaseButton>
<BaseIcon v-else class="text-muted" name="EllipsisHorizontalIcon" />
</template>
<!-- Copy PDF url -->
<BaseDropdownItem v-if="isDetailView" @click="copyPdfUrl">
<BaseIcon
name="LinkIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.copy_pdf_url') }}
</BaseDropdownItem>
<!-- Edit Estimate -->
<router-link
v-if="canEdit"
:to="`/admin/estimates/${row.id}/edit`"
>
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<!-- Delete Estimate -->
<BaseDropdownItem v-if="canDelete" @click="removeEstimate">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
<!-- View Estimate -->
<router-link
v-if="!isDetailView && canView"
:to="`estimates/${row.id}/view`"
>
<BaseDropdownItem>
<BaseIcon
name="EyeIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
<!-- Clone Estimate -->
<BaseDropdownItem v-if="canCreate" @click="cloneEstimateData">
<BaseIcon
name="DocumentTextIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.clone_estimate') }}
</BaseDropdownItem>
<!-- Convert into Invoice -->
<BaseDropdownItem v-if="canCreateInvoice" @click="convertToInvoice">
<BaseIcon
name="DocumentTextIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.convert_to_invoice') }}
</BaseDropdownItem>
<!-- Mark as Sent -->
<BaseDropdownItem
v-if="row.status !== 'SENT' && !isDetailView && canSend"
@click="onMarkAsSent"
>
<BaseIcon
name="CheckCircleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.mark_as_sent') }}
</BaseDropdownItem>
<!-- Send Estimate -->
<BaseDropdownItem
v-if="row.status !== 'SENT' && !isDetailView && canSend"
@click="sendEstimate"
>
<BaseIcon
name="PaperAirplaneIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.send_estimate') }}
</BaseDropdownItem>
<!-- Resend Estimate -->
<BaseDropdownItem v-if="canResendEstimate" @click="sendEstimate">
<BaseIcon
name="PaperAirplaneIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.resend_estimate') }}
</BaseDropdownItem>
<!-- Mark as Accepted -->
<BaseDropdownItem
v-if="row.status !== 'ACCEPTED' && canEdit"
@click="onMarkAsAccepted"
>
<BaseIcon
name="CheckCircleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.mark_as_accepted') }}
</BaseDropdownItem>
<!-- Mark as Rejected -->
<BaseDropdownItem
v-if="row.status !== 'REJECTED' && canEdit"
@click="onMarkAsRejected"
>
<BaseIcon
name="XCircleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('estimates.mark_as_rejected') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useEstimateStore } from '../store'
import type { Estimate } from '../../../../types/domain/estimate'
interface TableRef {
refresh: () => void
}
interface Props {
row: Estimate & Record<string, unknown>
table?: TableRef | null
canEdit?: boolean
canView?: boolean
canCreate?: boolean
canDelete?: boolean
canSend?: boolean
canCreateInvoice?: boolean
}
const props = withDefaults(defineProps<Props>(), {
table: null,
canEdit: false,
canView: false,
canCreate: false,
canDelete: false,
canSend: false,
canCreateInvoice: false,
})
const estimateStore = useEstimateStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isDetailView = computed<boolean>(() => route.name === 'estimates.view')
const canResendEstimate = computed<boolean>(() => {
return (
(props.row.status === 'SENT' || props.row.status === 'VIEWED') &&
!isDetailView.value &&
props.canSend
)
})
async function removeEstimate(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_delete'))
if (!confirmed) return
const res = await estimateStore.deleteEstimate({ ids: [props.row.id] })
if (res.data) {
props.table?.refresh()
if (res.data.success) {
router.push('/admin/estimates')
}
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
}
}
async function convertToInvoice(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_conversion'))
if (!confirmed) return
const res = await estimateStore.convertToInvoice(props.row.id)
if (res.data) {
router.push(`/admin/invoices/${res.data.data.id}/edit`)
}
}
async function onMarkAsSent(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_mark_as_sent'))
if (!confirmed) return
await estimateStore.markAsSent({ id: props.row.id, status: 'SENT' })
props.table?.refresh()
}
function sendEstimate(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('estimates.send_estimate'),
componentName: 'SendEstimateModal',
id: props.row.id,
data: props.row,
variant: 'lg',
})
}
async function onMarkAsAccepted(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_mark_as_accepted'))
if (!confirmed) return
await estimateStore.markAsAccepted({ id: props.row.id, status: 'ACCEPTED' })
props.table?.refresh()
}
async function onMarkAsRejected(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_mark_as_rejected'))
if (!confirmed) return
await estimateStore.markAsRejected({ id: props.row.id, status: 'REJECTED' })
props.table?.refresh()
}
function copyPdfUrl(): void {
const pdfUrl = `${window.location.origin}/estimates/pdf/${props.row.unique_hash}`
navigator.clipboard.writeText(pdfUrl).catch(() => {
const textarea = document.createElement('textarea')
textarea.value = pdfUrl
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
})
}
async function cloneEstimateData(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_clone'))
if (!confirmed) return
const res = await estimateStore.cloneEstimate({ id: props.row.id })
router.push(`/admin/estimates/${res.data.data.id}/edit`)
}
</script>

View File

@@ -0,0 +1,12 @@
export { useEstimateStore } from './store'
export type { EstimateStore, EstimateFormData, EstimateState } from './store'
export { estimateRoutes } from './routes'
// Views
export { default as EstimateIndexView } from './views/EstimateIndexView.vue'
export { default as EstimateCreateView } from './views/EstimateCreateView.vue'
export { default as EstimateDetailView } from './views/EstimateDetailView.vue'
// Components
export { default as EstimateBasicFields } from './components/EstimateBasicFields.vue'
export { default as EstimateDropdown } from './components/EstimateDropdown.vue'

View File

@@ -0,0 +1,44 @@
import type { RouteRecordRaw } from 'vue-router'
const EstimateIndexView = () => import('./views/EstimateIndexView.vue')
const EstimateCreateView = () => import('./views/EstimateCreateView.vue')
const EstimateDetailView = () => import('./views/EstimateDetailView.vue')
export const estimateRoutes: RouteRecordRaw[] = [
{
path: 'estimates',
name: 'estimates.index',
component: EstimateIndexView,
meta: {
ability: 'view-estimate',
title: 'estimates.title',
},
},
{
path: 'estimates/create',
name: 'estimates.create',
component: EstimateCreateView,
meta: {
ability: 'create-estimate',
title: 'estimates.new_estimate',
},
},
{
path: 'estimates/:id/edit',
name: 'estimates.edit',
component: EstimateCreateView,
meta: {
ability: 'edit-estimate',
title: 'estimates.edit_estimate',
},
},
{
path: 'estimates/:id/view',
name: 'estimates.view',
component: EstimateDetailView,
meta: {
ability: 'view-estimate',
title: 'estimates.title',
},
},
]

View File

@@ -0,0 +1,548 @@
import { defineStore } from 'pinia'
import { estimateService } from '../../../api/services/estimate.service'
import type {
EstimateListParams,
EstimateListResponse,
SendEstimatePayload,
EstimateStatusPayload,
EstimateTemplate,
} from '../../../api/services/estimate.service'
import type { Estimate, EstimateItem, DiscountType } from '../../../types/domain/estimate'
import type { Invoice } from '../../../types/domain/invoice'
import type { Tax, TaxType } from '../../../types/domain/tax'
import type { Currency } from '../../../types/domain/currency'
import type { Customer } from '../../../types/domain/customer'
import type { Note } from '../../../types/domain/note'
import type { CustomFieldValue } from '../../../types/domain/custom-field'
import type { DocumentTax, DocumentItem } from '../../shared/document-form/use-document-calculations'
// ----------------------------------------------------------------
// Stub factories
// ----------------------------------------------------------------
function createTaxStub(): DocumentTax {
return {
id: crypto.randomUUID(),
name: '',
tax_type_id: 0,
type: 'GENERAL',
amount: 0,
percent: null,
compound_tax: false,
calculation_type: null,
fixed_amount: 0,
}
}
function createEstimateItemStub(): DocumentItem {
return {
id: crypto.randomUUID(),
estimate_id: null,
item_id: null,
name: '',
description: null,
quantity: 1,
price: 0,
discount_type: 'fixed',
discount_val: 0,
discount: 0,
total: 0,
sub_total: 0,
totalTax: 0,
totalSimpleTax: 0,
totalCompoundTax: 0,
tax: 0,
taxes: [createTaxStub()],
unit_name: null,
}
}
export interface EstimateFormData {
id: number | null
customer: Customer | null
template_name: string | null
tax_per_item: string | null
tax_included: boolean
sales_tax_type: string | null
sales_tax_address_type: string | null
discount_per_item: string | null
estimate_date: string
expiry_date: string
estimate_number: string
customer_id: number | null
sub_total: number
total: number
tax: number
notes: string | null
discount_type: DiscountType
discount_val: number
reference_number: string | null
discount: number
items: DocumentItem[]
taxes: DocumentTax[]
customFields: CustomFieldValue[]
fields: CustomFieldValue[]
selectedNote: Note | null
selectedCurrency: Currency | Record<string, unknown> | string
unique_hash?: string
exchange_rate?: number | null
currency_id?: number
}
function createEstimateStub(): EstimateFormData {
return {
id: null,
customer: null,
template_name: '',
tax_per_item: null,
tax_included: false,
sales_tax_type: null,
sales_tax_address_type: null,
discount_per_item: null,
estimate_date: '',
expiry_date: '',
estimate_number: '',
customer_id: null,
sub_total: 0,
total: 0,
tax: 0,
notes: '',
discount_type: 'fixed',
discount_val: 0,
reference_number: null,
discount: 0,
items: [createEstimateItemStub()],
taxes: [],
customFields: [],
fields: [],
selectedNote: null,
selectedCurrency: '',
}
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface EstimateState {
templates: EstimateTemplate[]
estimates: Estimate[]
selectAllField: boolean
selectedEstimates: number[]
totalEstimateCount: number
isFetchingInitialSettings: boolean
showExchangeRate: boolean
newEstimate: EstimateFormData
}
export const useEstimateStore = defineStore('estimate', {
state: (): EstimateState => ({
templates: [],
estimates: [],
selectAllField: false,
selectedEstimates: [],
totalEstimateCount: 0,
isFetchingInitialSettings: false,
showExchangeRate: false,
newEstimate: createEstimateStub(),
}),
getters: {
getSubTotal(state): number {
return state.newEstimate.items.reduce(
(sum: number, item: DocumentItem) => sum + (item.total ?? 0),
0,
)
},
getNetTotal(): number {
return this.getSubtotalWithDiscount - this.getTotalTax
},
getTotalSimpleTax(state): number {
return state.newEstimate.taxes.reduce(
(sum: number, tax: DocumentTax) => {
if (!tax.compound_tax) return sum + (tax.amount ?? 0)
return sum
},
0,
)
},
getTotalCompoundTax(state): number {
return state.newEstimate.taxes.reduce(
(sum: number, tax: DocumentTax) => {
if (tax.compound_tax) return sum + (tax.amount ?? 0)
return sum
},
0,
)
},
getTotalTax(): number {
if (
this.newEstimate.tax_per_item === 'NO' ||
this.newEstimate.tax_per_item === null
) {
return this.getTotalSimpleTax + this.getTotalCompoundTax
}
return this.newEstimate.items.reduce(
(sum: number, item: DocumentItem) => sum + (item.tax ?? 0),
0,
)
},
getSubtotalWithDiscount(): number {
return this.getSubTotal - this.newEstimate.discount_val
},
getTotal(): number {
if (this.newEstimate.tax_included) {
return this.getSubtotalWithDiscount
}
return this.getSubtotalWithDiscount + this.getTotalTax
},
isEdit(state): boolean {
return !!state.newEstimate.id
},
},
actions: {
resetCurrentEstimate(): void {
this.newEstimate = createEstimateStub()
},
async previewEstimate(params: { id: number }): Promise<unknown> {
return estimateService.sendPreview(params.id, params)
},
async fetchEstimates(
params: EstimateListParams & { estimate_number?: string },
): Promise<{ data: EstimateListResponse }> {
const response = await estimateService.list(params)
this.estimates = response.data
this.totalEstimateCount = response.meta.estimate_total_count
return { data: response }
},
async getNextNumber(
params?: Record<string, unknown>,
setState = false,
): Promise<{ data: { nextNumber: string } }> {
const response = await estimateService.getNextNumber(params as never)
if (setState) {
this.newEstimate.estimate_number = response.nextNumber
}
return { data: response }
},
async fetchEstimate(id: number): Promise<{ data: { data: Estimate } }> {
const response = await estimateService.get(id)
this.setEstimateData(response.data)
this.setCustomerAddresses(this.newEstimate.customer)
return { data: response }
},
setEstimateData(estimate: Estimate): void {
Object.assign(this.newEstimate, estimate)
if (this.newEstimate.tax_per_item === 'YES') {
this.newEstimate.items.forEach((item) => {
if (item.taxes && !item.taxes.length) {
item.taxes.push(createTaxStub())
}
})
}
if (this.newEstimate.discount_per_item === 'YES') {
this.newEstimate.items.forEach((item, index) => {
if (item.discount_type === 'fixed') {
this.newEstimate.items[index].discount = item.discount / 100
}
})
} else {
if (this.newEstimate.discount_type === 'fixed') {
this.newEstimate.discount = this.newEstimate.discount / 100
}
}
},
setCustomerAddresses(customer: Customer | null): void {
if (!customer) return
const business = (customer as Record<string, unknown>).customer_business as
| Record<string, unknown>
| undefined
if (business?.billing_address) {
;(this.newEstimate.customer as Record<string, unknown>).billing_address =
business.billing_address
}
if (business?.shipping_address) {
;(this.newEstimate.customer as Record<string, unknown>).shipping_address =
business.shipping_address
}
},
addSalesTaxUs(taxTypes: TaxType[]): void {
const salesTax = createTaxStub()
const found = this.newEstimate.taxes.find(
(t) => t.name === 'Sales Tax' && t.type === 'MODULE',
)
if (found) {
for (const key in found) {
if (Object.prototype.hasOwnProperty.call(salesTax, key)) {
;(salesTax as Record<string, unknown>)[key] = (
found as Record<string, unknown>
)[key]
}
}
salesTax.id = found.tax_type_id
taxTypes.push(salesTax as unknown as TaxType)
}
},
async sendEstimate(data: SendEstimatePayload): Promise<unknown> {
return estimateService.send(data)
},
async addEstimate(data: Record<string, unknown>): Promise<{ data: { data: Estimate } }> {
const response = await estimateService.create(data as never)
this.estimates = [...this.estimates, response.data]
return { data: response }
},
async deleteEstimate(payload: { ids: number[] }): Promise<{ data: { success: boolean } }> {
const response = await estimateService.delete(payload)
const id = payload.ids[0]
const index = this.estimates.findIndex((est) => est.id === id)
if (index !== -1) {
this.estimates.splice(index, 1)
}
return { data: response }
},
async deleteMultipleEstimates(): Promise<{ data: { success: boolean } }> {
const response = await estimateService.delete({
ids: this.selectedEstimates,
})
this.selectedEstimates.forEach((estId) => {
const index = this.estimates.findIndex((est) => est.id === estId)
if (index !== -1) {
this.estimates.splice(index, 1)
}
})
this.selectedEstimates = []
return { data: response }
},
async updateEstimate(data: Record<string, unknown>): Promise<{ data: { data: Estimate } }> {
const response = await estimateService.update(data.id as number, data as never)
const pos = this.estimates.findIndex((est) => est.id === response.data.id)
if (pos !== -1) {
this.estimates[pos] = response.data
}
return { data: response }
},
async cloneEstimate(data: { id: number }): Promise<{ data: { data: Estimate } }> {
const response = await estimateService.clone(data.id)
return { data: response }
},
async markAsAccepted(data: EstimateStatusPayload): Promise<unknown> {
const response = await estimateService.changeStatus({
...data,
status: 'ACCEPTED',
})
const pos = this.estimates.findIndex((est) => est.id === data.id)
if (pos !== -1 && this.estimates[pos]) {
this.estimates[pos].status = 'ACCEPTED' as Estimate['status']
}
return response
},
async markAsRejected(data: EstimateStatusPayload): Promise<unknown> {
const response = await estimateService.changeStatus({
...data,
status: 'REJECTED',
})
return response
},
async markAsSent(data: EstimateStatusPayload): Promise<unknown> {
const response = await estimateService.changeStatus(data)
const pos = this.estimates.findIndex((est) => est.id === data.id)
if (pos !== -1 && this.estimates[pos]) {
this.estimates[pos].status = 'SENT' as Estimate['status']
}
return response
},
async convertToInvoice(id: number): Promise<{ data: { data: Invoice } }> {
const response = await estimateService.convertToInvoice(id)
return { data: response }
},
async searchEstimate(queryString: string): Promise<unknown> {
return estimateService.list(
Object.fromEntries(new URLSearchParams(queryString)) as never,
)
},
selectEstimate(data: number[]): void {
this.selectedEstimates = data
this.selectAllField =
this.selectedEstimates.length === this.estimates.length
},
selectAllEstimates(): void {
if (this.selectedEstimates.length === this.estimates.length) {
this.selectedEstimates = []
this.selectAllField = false
} else {
this.selectedEstimates = this.estimates.map((est) => est.id)
this.selectAllField = true
}
},
async selectCustomer(id: number): Promise<unknown> {
const { customerService } = await import(
'../../../api/services/customer.service'
)
const response = await customerService.get(id)
this.newEstimate.customer = response.data as unknown as Customer
this.newEstimate.customer_id = response.data.id
return response
},
async fetchEstimateTemplates(): Promise<{
data: { estimateTemplates: EstimateTemplate[] }
}> {
const response = await estimateService.getTemplates()
this.templates = response.estimateTemplates
return { data: response }
},
setTemplate(name: string): void {
this.newEstimate.template_name = name
},
resetSelectedCustomer(): void {
this.newEstimate.customer = null
this.newEstimate.customer_id = null
},
selectNote(data: Note): void {
this.newEstimate.selectedNote = null
this.newEstimate.selectedNote = data
},
resetSelectedNote(): void {
this.newEstimate.selectedNote = null
},
addItem(): void {
this.newEstimate.items.push(createEstimateItemStub())
},
updateItem(data: DocumentItem & { index: number }): void {
Object.assign(this.newEstimate.items[data.index], { ...data })
},
removeItem(index: number): void {
this.newEstimate.items.splice(index, 1)
},
deselectItem(index: number): void {
this.newEstimate.items[index] = createEstimateItemStub()
},
async fetchEstimateInitialSettings(
isEdit: boolean,
routeParams?: { id?: string; query?: Record<string, string> },
companySettings?: Record<string, string>,
companyCurrency?: Currency,
userSettings?: Record<string, string>,
): Promise<void> {
this.isFetchingInitialSettings = true
if (companyCurrency) {
this.newEstimate.selectedCurrency = companyCurrency
}
// If customer is specified in route query
if (routeParams?.query?.customer) {
try {
await this.selectCustomer(Number(routeParams.query.customer))
} catch {
// Silently fail
}
}
const editActions: Promise<unknown>[] = []
if (!isEdit && companySettings) {
this.newEstimate.tax_per_item = companySettings.tax_per_item ?? null
this.newEstimate.sales_tax_type = companySettings.sales_tax_type ?? null
this.newEstimate.sales_tax_address_type =
companySettings.sales_tax_address_type ?? null
this.newEstimate.discount_per_item =
companySettings.discount_per_item ?? null
const now = new Date()
this.newEstimate.estimate_date = formatDate(now, 'YYYY-MM-DD')
if (companySettings.estimate_set_expiry_date_automatically === 'YES') {
const expiryDate = new Date(now)
expiryDate.setDate(
expiryDate.getDate() +
Number(companySettings.estimate_expiry_date_days ?? 7),
)
this.newEstimate.expiry_date = formatDate(expiryDate, 'YYYY-MM-DD')
}
} else if (isEdit && routeParams?.id) {
editActions.push(this.fetchEstimate(Number(routeParams.id)))
}
try {
const [, , templatesRes, nextNumRes] = await Promise.all([
Promise.resolve(), // placeholder for items fetch
this.resetSelectedNote(),
this.fetchEstimateTemplates(),
this.getNextNumber(),
Promise.resolve(), // placeholder for tax types fetch
...editActions,
])
if (!isEdit) {
if (nextNumRes?.data?.nextNumber) {
this.newEstimate.estimate_number = nextNumRes.data.nextNumber
}
if (this.templates.length) {
this.setTemplate(this.templates[0].name)
if (userSettings?.default_estimate_template) {
this.newEstimate.template_name =
userSettings.default_estimate_template
}
}
}
} catch {
// Error handling
} finally {
this.isFetchingInitialSettings = false
}
},
},
})
/** Simple date formatter without moment dependency */
function formatDate(date: Date, _format: string): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export type EstimateStore = ReturnType<typeof useEstimateStore>

View File

@@ -0,0 +1,241 @@
<template>
<BasePage class="relative estimate-create-page">
<form @submit.prevent="submitForm">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('estimates.estimate', 2)"
to="/admin/estimates"
/>
<BaseBreadcrumbItem
v-if="isEdit"
:title="$t('estimates.edit_estimate')"
to="#"
active
/>
<BaseBreadcrumbItem v-else :title="$t('estimates.new_estimate')" to="#" active />
</BaseBreadcrumb>
<template #actions>
<router-link
v-if="isEdit"
: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="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('estimates.save_estimate') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Select Customer & Basic Fields -->
<EstimateBasicFields
:v="v$"
:is-loading="isLoadingContent"
:is-edit="isEdit"
/>
<BaseScrollPane>
<!-- Estimate Items -->
<DocumentItemsTable
: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 -->
<DocumentNotes
:store="estimateStore"
store-prop="newEstimate"
:fields="estimateNoteFieldList"
type="Estimate"
/>
<!-- Estimate Template Button -->
<TemplateSelectButton
:store="estimateStore"
store-prop="newEstimate"
:is-mark-as-default="isMarkAsDefault"
/>
</div>
<DocumentTotals
:currency="estimateStore.newEstimate.selectedCurrency"
:is-loading="isLoadingContent"
:store="estimateStore"
store-prop="newEstimate"
tax-popup-type="estimate"
/>
</div>
</BaseScrollPane>
</form>
</BasePage>
</template>
<script setup lang="ts">
import { computed, 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 { useEstimateStore } from '../store'
import EstimateBasicFields from '../components/EstimateBasicFields.vue'
import {
DocumentItemsTable,
DocumentTotals,
DocumentNotes,
TemplateSelectButton,
} from '../../../shared/document-form'
const estimateStore = useEstimateStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const estimateValidationScope = 'newEstimate'
const isSaving = ref<boolean>(false)
const isMarkAsDefault = ref<boolean>(false)
const estimateNoteFieldList = ref<Record<string, unknown> | null>(null)
const isLoadingContent = computed<boolean>(
() => estimateStore.isFetchingInitialSettings,
)
const pageTitle = computed<string>(() =>
isEdit.value ? t('estimates.edit_estimate') : t('estimates.new_estimate'),
)
const isEdit = computed<boolean>(() => route.name === 'estimates.edit')
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(() => {
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 },
)
// Initialization
estimateStore.resetCurrentEstimate()
v$.value.$reset
estimateStore.fetchEstimateInitialSettings(
isEdit.value,
{ id: route.params.id as string, query: route.query as Record<string, string> },
)
watch(
() => estimateStore.newEstimate.customer,
(newVal) => {
if (newVal && (newVal as Record<string, unknown>).currency) {
estimateStore.newEstimate.selectedCurrency = (
newVal as Record<string, unknown>
).currency as Record<string, unknown>
}
},
)
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data: Record<string, unknown> = {
...structuredClone(estimateStore.newEstimate),
sub_total: estimateStore.getSubTotal,
total: estimateStore.getTotal,
tax: estimateStore.getTotalTax,
}
const items = data.items as Array<Record<string, unknown>>
if (data.discount_per_item === 'YES') {
items.forEach((item, index) => {
if (item.discount_type === 'fixed') {
items[index].discount = Math.round((item.discount as number) * 100)
}
})
} else {
if (data.discount_type === 'fixed') {
data.discount = Math.round((data.discount as number) * 100)
}
}
const taxes = data.taxes as Array<Record<string, unknown>>
if (data.tax_per_item !== 'YES' && taxes.length) {
data.tax_type_ids = taxes.map((tax) => tax.tax_type_id)
}
try {
const action = isEdit.value
? estimateStore.updateEstimate
: estimateStore.addEstimate
const res = await action(data)
if (res.data.data) {
router.push(`/admin/estimates/${res.data.data.id}/view`)
}
} catch (err) {
console.error(err)
}
isSaving.value = false
}
</script>

View File

@@ -0,0 +1,416 @@
<template>
<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' && canEdit"
:disabled="isMarkAsSent"
:content-loading="isLoadingEstimate"
variant="primary-outline"
@click="onMarkAsSent"
>
{{ $t('estimates.mark_as_sent') }}
</BaseButton>
</div>
<BaseButton
v-if="estimateData.status === 'DRAFT' && canSend"
:content-loading="isLoadingEstimate"
variant="primary"
class="text-sm"
@click="onSendEstimate"
>
{{ $t('estimates.send_estimate') }}
</BaseButton>
<EstimateDropdown
class="ml-3"
:row="estimateData"
:can-edit="canEdit"
:can-view="canView"
:can-create="canCreate"
:can-delete="canDelete"
:can-send="canSend"
:can-create-invoice="canCreateInvoice"
/>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-[6.4rem] ml-56 bg-surface xl:ml-64 w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-2 border border-line-default 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="MagnifyingGlassIcon" class="text-subtle" />
</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="FunnelIcon" />
</BaseButton>
</template>
<div
class="px-4 py-1 pb-2 mb-1 mb-2 text-sm border-b border-line-default 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:model-value="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:model-value="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:model-value="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="BarsArrowUpIcon" />
<BaseIcon v-else name="BarsArrowDownIcon" />
</BaseButton>
</div>
</div>
<div
ref="estimateListSection"
class="h-full overflow-y-scroll border-l border-line-default border-solid base-scroll"
>
<div v-for="(estimate, index) in estimateList" :key="index">
<router-link
v-if="estimate"
:id="'estimate-' + estimate.id"
:to="`/admin/estimates/${estimate.id}/view`"
:class="[
'flex justify-between side-estimate p-4 cursor-pointer hover:bg-hover-strong items-center border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-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 ?? ''"
class="pr-2 mb-2 text-sm not-italic font-normal leading-5 text-heading capitalize truncate"
/>
<div
class="mt-1 mb-2 text-xs not-italic font-medium leading-5 text-body"
>
{{ estimate.estimate_number }}
</div>
<BaseEstimateStatusBadge
:status="estimate.status"
class="px-1 text-xs"
>
<BaseEstimateStatusLabel :status="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-heading"
/>
<div
class="text-sm not-italic font-normal leading-5 text-right text-body est-date"
>
{{ estimate.formatted_estimate_date }}
</div>
</div>
</router-link>
</div>
<div v-if="isLoading" class="flex justify-center p-4 items-center">
<LoadingIcon class="h-6 m-1 animate-spin text-primary-400" />
</div>
<p
v-if="!estimateList?.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-body"
>
{{ $t('estimates.no_matching_estimates') }}
</p>
</div>
</div>
<!-- PDF Preview -->
<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-xl bg-surface frame-style"
/>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useEstimateStore } from '../store'
import EstimateDropdown from '../components/EstimateDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import type { Estimate } from '../../../../types/domain/estimate'
interface Props {
canEdit?: boolean
canView?: boolean
canCreate?: boolean
canDelete?: boolean
canSend?: boolean
canCreateInvoice?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canView: false,
canCreate: false,
canDelete: false,
canSend: false,
canCreateInvoice: false,
})
const estimateStore = useEstimateStore()
const { t } = useI18n()
const route = useRoute()
const estimateData = ref<Estimate | null>(null)
const isMarkAsSent = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const isLoadingEstimate = ref<boolean>(false)
const estimateList = ref<Estimate[] | null>(null)
const currentPageNumber = ref<number>(1)
const lastPageNumber = ref<number>(1)
const estimateListSection = ref<HTMLElement | null>(null)
interface SearchData {
orderBy: string | null
orderByField: string | null
searchText: string | null
}
const searchData = reactive<SearchData>({
orderBy: null,
orderByField: null,
searchText: null,
})
const pageTitle = computed<string>(() => estimateData.value?.estimate_number ?? '')
const getOrderBy = computed<boolean>(() => {
return searchData.orderBy === 'asc' || searchData.orderBy === null
})
const shareableLink = computed<string>(() => {
return `/estimates/pdf/${estimateData.value?.unique_hash ?? ''}`
})
watch(route, (to) => {
if (to.name === 'estimates.view') {
loadEstimate()
}
})
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadEstimates(
pageNumber?: number,
fromScrollListener = false,
): Promise<void> {
if (isLoading.value) return
const params: Record<string, unknown> = {}
if (searchData.searchText) {
params.search = searchData.searchText
}
if (searchData.orderBy != null) {
params.orderBy = searchData.orderBy
}
if (searchData.orderByField != null) {
params.orderByField = searchData.orderByField
}
isLoading.value = true
const response = await estimateStore.fetchEstimates({
page: pageNumber,
...params,
} as never)
isLoading.value = false
estimateList.value = estimateList.value ?? []
estimateList.value = [...estimateList.value, ...response.data.data]
currentPageNumber.value = pageNumber ?? 1
lastPageNumber.value = response.data.meta.last_page
const estimateFound = estimateList.value.find(
(est) => est.id === Number(route.params.id),
)
if (
!fromScrollListener &&
!estimateFound &&
currentPageNumber.value < lastPageNumber.value &&
Object.keys(params).length === 0
) {
loadEstimates(++currentPageNumber.value)
}
if (estimateFound && !fromScrollListener) {
setTimeout(() => scrollToEstimate(), 500)
}
}
function scrollToEstimate(): void {
const el = document.getElementById(`estimate-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
addScrollListener()
}
}
function addScrollListener(): void {
estimateListSection.value?.addEventListener('scroll', (ev) => {
const target = ev.target as HTMLElement
if (
target.scrollTop > 0 &&
target.scrollTop + target.clientHeight > target.scrollHeight - 200
) {
if (currentPageNumber.value < lastPageNumber.value) {
loadEstimates(++currentPageNumber.value, true)
}
}
})
}
async function loadEstimate(): Promise<void> {
isLoadingEstimate.value = true
const response = await estimateStore.fetchEstimate(Number(route.params.id))
if (response.data) {
isLoadingEstimate.value = false
estimateData.value = { ...response.data.data } as Estimate
}
}
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function onSearched(): void {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
estimateList.value = []
loadEstimates()
}, 500)
}
function sortData(): void {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
} else {
searchData.orderBy = 'asc'
}
onSearched()
}
async function onMarkAsSent(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_mark_as_sent'))
if (!confirmed) return
isMarkAsSent.value = false
await estimateStore.markAsSent({
id: estimateData.value!.id,
status: 'SENT',
})
estimateData.value!.status = 'SENT' as Estimate['status']
isMarkAsSent.value = true
isMarkAsSent.value = false
}
function onSendEstimate(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('estimates.send_estimate'),
componentName: 'SendEstimateModal',
id: estimateData.value!.id,
data: estimateData.value,
})
}
function updateSentEstimate(): void {
if (!estimateList.value) return
const pos = estimateList.value.findIndex(
(est) => est.id === estimateData.value?.id,
)
if (pos !== -1 && estimateList.value[pos]) {
estimateList.value[pos].status = 'SENT' as Estimate['status']
estimateData.value!.status = 'SENT' as Estimate['status']
}
}
// Initialize
loadEstimates()
loadEstimate()
</script>

View File

@@ -0,0 +1,460 @@
<template>
<BasePage>
<BasePageHeader :title="$t('estimates.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('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="FunnelIcon"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link v-if="canCreate" 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>
<!-- Filters -->
<BaseFilterWrapper
v-show="showFilters"
:row-on-xl="true"
@clear="clearFilter"
>
<BaseInputGroup :label="$t('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="statusOptions"
searchable
:placeholder="$t('general.select_a_status')"
@update:model-value="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>
<!-- Empty State -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('estimates.no_estimates')"
:description="$t('estimates.list_of_estimates')"
>
<template #actions>
<BaseButton
v-if="canCreate"
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>
<!-- Table -->
<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-line-default border-solid"
>
<BaseTabGroup class="-mb-5" @change="setStatusFilter">
<BaseTab :title="$t('general.all')" filter="" />
<BaseTab :title="$t('general.draft')" filter="DRAFT" />
<BaseTab :title="$t('general.sent')" filter="SENT" />
</BaseTabGroup>
<BaseDropdown
v-if="estimateStore.selectedEstimates.length && canDelete"
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-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="estimateColumns"
:placeholder-count="estimateStore.totalEstimateCount >= 20 ? 10 : 5"
:key="tableKey"
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>
<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" />
</template>
<template #cell-status="{ row }">
<BaseEstimateStatusBadge :status="row.data.status" class="px-3 py-1">
<BaseEstimateStatusLabel :status="row.data.status" />
</BaseEstimateStatusBadge>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<template v-if="hasAtLeastOneAbility" #cell-actions="{ row }">
<EstimateDropdown
:row="row.data"
:table="tableRef"
:can-edit="canEdit"
:can-view="canView"
:can-create="canCreate"
:can-delete="canDelete"
:can-send="canSend"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useEstimateStore } from '../store'
import EstimateDropdown from '../components/EstimateDropdown.vue'
import type { Estimate } from '../../../../types/domain/estimate'
interface Props {
canCreate?: boolean
canEdit?: boolean
canView?: boolean
canDelete?: boolean
canSend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canCreate: false,
canEdit: false,
canView: false,
canDelete: false,
canSend: false,
})
const estimateStore = useEstimateStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const tableKey = ref<number>(0)
const showFilters = ref<boolean>(false)
const isRequestOngoing = ref<boolean>(true)
const activeTab = ref<string>('general.draft')
interface StatusOption {
label: string
value: string
}
const statusOptions = ref<StatusOption[]>([
{ label: t('estimates.draft'), value: 'DRAFT' },
{ label: t('estimates.sent'), value: 'SENT' },
{ label: t('estimates.viewed'), value: 'VIEWED' },
{ label: t('estimates.expired'), value: 'EXPIRED' },
{ label: t('estimates.accepted'), value: 'ACCEPTED' },
{ label: t('estimates.rejected'), value: 'REJECTED' },
])
interface EstimateFilters {
customer_id: string | number
status: string
from_date: string
to_date: string
estimate_number: string
}
const filters = reactive<EstimateFilters>({
customer_id: '',
status: '',
from_date: '',
to_date: '',
estimate_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !estimateStore.totalEstimateCount && !isRequestOngoing.value,
)
const selectField = computed<number[]>({
get: () => estimateStore.selectedEstimates,
set: (val: number[]) => {
estimateStore.selectEstimate(val)
},
})
const hasAtLeastOneAbility = computed<boolean>(() => {
return props.canCreate || props.canEdit || props.canView || props.canSend
})
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const estimateColumns = computed<TableColumn[]>(() => [
{
key: 'checkbox',
thClass: 'extra w-10 pr-0',
sortable: false,
tdClass: 'font-medium text-heading pr-0',
},
{
key: 'estimate_date',
label: t('estimates.date'),
thClass: 'extra',
tdClass: 'font-medium text-muted',
},
{ 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-heading',
},
{
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 clearStatusSearch(): void {
filters.status = ''
refreshTable()
}
function refreshTable(): void {
tableRef.value?.refresh()
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: Estimate[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
customer_id: filters.customer_id ? Number(filters.customer_id) : undefined,
status: filters.status || undefined,
from_date: filters.from_date || undefined,
to_date: filters.to_date || undefined,
estimate_number: filters.estimate_number || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: (sort.order || 'desc') as 'asc' | 'desc',
page,
}
isRequestOngoing.value = true
const 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: { title: string }): void {
if (activeTab.value === val.title) return
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(): void {
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
tableKey.value += 1
refreshTable()
}
function clearFilter(): void {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.estimate_number = ''
activeTab.value = t('general.all')
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function removeMultipleEstimates(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_delete'))
if (!confirmed) return
const res = await estimateStore.deleteMultipleEstimates()
if (res.data) {
refreshTable()
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
}
}
function setActiveTab(val: string): void {
const tabMap: Record<string, string> = {
DRAFT: t('general.draft'),
SENT: t('general.sent'),
VIEWED: t('estimates.viewed'),
EXPIRED: t('estimates.expired'),
ACCEPTED: t('estimates.accepted'),
REJECTED: t('estimates.rejected'),
}
activeTab.value = tabMap[val] ?? t('general.all')
}
</script>

View File

@@ -0,0 +1,101 @@
<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 rounded-xl shadow border border-line-light bg-surface p-5"
>
<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"
:enable-time="enableTime"
:time24hr="time24h"
/>
</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 lang="ts">
import { computed } from 'vue'
import { ExchangeRateConverter } from '../../../shared/document-form'
import { useInvoiceStore } from '../store'
interface ValidationField {
$error: boolean
$errors: Array<{ $message: string }>
$touch: () => void
}
interface Props {
v: Record<string, ValidationField>
isLoading?: boolean
isEdit?: boolean
companySettings?: Record<string, string>
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
isEdit: false,
companySettings: () => ({}),
})
const invoiceStore = useInvoiceStore()
const enableTime = computed<boolean>(() => {
return props.companySettings?.invoice_use_time === 'YES'
})
const time24h = computed<boolean>(() => {
const format = props.companySettings?.carbon_time_format ?? ''
return format.indexOf('H') > -1
})
</script>

View File

@@ -0,0 +1,218 @@
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="isDetailView" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Invoice -->
<router-link
v-if="canEdit"
:to="`/admin/invoices/${row.id}/edit`"
>
<BaseDropdownItem v-show="row.allow_edit">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<!-- Copy PDF url -->
<BaseDropdownItem v-if="isDetailView" @click="copyPdfUrl">
<BaseIcon
name="LinkIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.copy_pdf_url') }}
</BaseDropdownItem>
<!-- View Invoice -->
<router-link
v-if="!isDetailView && canView"
:to="`/admin/invoices/${row.id}/view`"
>
<BaseDropdownItem>
<BaseIcon
name="EyeIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
<!-- Send Invoice Mail -->
<BaseDropdownItem v-if="canSendInvoice" @click="sendInvoice">
<BaseIcon
name="PaperAirplaneIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('invoices.send_invoice') }}
</BaseDropdownItem>
<!-- Resend Invoice -->
<BaseDropdownItem v-if="canReSendInvoice" @click="sendInvoice">
<BaseIcon
name="PaperAirplaneIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('invoices.resend_invoice') }}
</BaseDropdownItem>
<!-- Record Payment -->
<router-link :to="`/admin/payments/${row.id}/create`">
<BaseDropdownItem
v-if="row.status === 'SENT' && !isDetailView"
>
<BaseIcon
name="CreditCardIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('invoices.record_payment') }}
</BaseDropdownItem>
</router-link>
<!-- Mark as Sent -->
<BaseDropdownItem v-if="canSendInvoice" @click="onMarkAsSent">
<BaseIcon
name="CheckCircleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('invoices.mark_as_sent') }}
</BaseDropdownItem>
<!-- Clone Invoice -->
<BaseDropdownItem v-if="canCreate" @click="cloneInvoiceData">
<BaseIcon
name="DocumentTextIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('invoices.clone_invoice') }}
</BaseDropdownItem>
<!-- Delete Invoice -->
<BaseDropdownItem v-if="canDelete" @click="removeInvoice">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useInvoiceStore } from '../store'
import type { Invoice } from '../../../../types/domain/invoice'
interface TableRef {
refresh: () => void
}
interface Props {
row: Invoice & Record<string, unknown>
table?: TableRef | null
loadData?: () => void
canEdit?: boolean
canView?: boolean
canCreate?: boolean
canDelete?: boolean
canSend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
table: null,
loadData: () => {},
canEdit: false,
canView: false,
canCreate: false,
canDelete: false,
canSend: false,
})
const invoiceStore = useInvoiceStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isDetailView = computed<boolean>(() => route.name === 'invoices.view')
const canReSendInvoice = computed<boolean>(() => {
return (
(props.row.status === 'SENT' || props.row.status === 'VIEWED') &&
props.canSend
)
})
const canSendInvoice = computed<boolean>(() => {
return (
props.row.status === 'DRAFT' &&
!isDetailView.value &&
props.canSend
)
})
async function removeInvoice(): Promise<void> {
// In v2, use a dialog composable or store
const confirmed = window.confirm(t('invoices.confirm_delete'))
if (!confirmed) return
const res = await invoiceStore.deleteInvoice({ ids: [props.row.id] })
if (res.data.success) {
router.push('/admin/invoices')
props.table?.refresh()
invoiceStore.$patch((state) => {
state.selectedInvoices = []
state.selectAllField = false
})
}
}
async function cloneInvoiceData(): Promise<void> {
const confirmed = window.confirm(t('invoices.confirm_clone'))
if (!confirmed) return
const res = await invoiceStore.cloneInvoice({ id: props.row.id })
router.push(`/admin/invoices/${res.data.data.id}/edit`)
}
async function onMarkAsSent(): Promise<void> {
const confirmed = window.confirm(t('invoices.invoice_mark_as_sent'))
if (!confirmed) return
await invoiceStore.markAsSent({ id: props.row.id, status: 'SENT' })
props.table?.refresh()
}
function sendInvoice(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('invoices.send_invoice'),
componentName: 'SendInvoiceModal',
id: props.row.id,
data: props.row,
variant: 'sm',
})
}
function copyPdfUrl(): void {
const pdfUrl = `${window.location.origin}/invoices/pdf/${props.row.unique_hash}`
navigator.clipboard.writeText(pdfUrl).catch(() => {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = pdfUrl
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
})
}
</script>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
/**
* Re-export of the base InvoiceStatusBadge component.
* Use this import path within the invoices feature for convenience.
*/
</script>
<template>
<BaseInvoiceStatusBadge v-bind="$attrs">
<slot />
</BaseInvoiceStatusBadge>
</template>

View File

@@ -0,0 +1,13 @@
export { useInvoiceStore } from './store'
export type { InvoiceStore, InvoiceFormData, InvoiceState } from './store'
export { invoiceRoutes } from './routes'
// Views
export { default as InvoiceIndexView } from './views/InvoiceIndexView.vue'
export { default as InvoiceCreateView } from './views/InvoiceCreateView.vue'
export { default as InvoiceDetailView } from './views/InvoiceDetailView.vue'
// Components
export { default as InvoiceBasicFields } from './components/InvoiceBasicFields.vue'
export { default as InvoiceStatusBadge } from './components/InvoiceStatusBadge.vue'
export { default as InvoiceDropdown } from './components/InvoiceDropdown.vue'

View File

@@ -0,0 +1,44 @@
import type { RouteRecordRaw } from 'vue-router'
const InvoiceIndexView = () => import('./views/InvoiceIndexView.vue')
const InvoiceCreateView = () => import('./views/InvoiceCreateView.vue')
const InvoiceDetailView = () => import('./views/InvoiceDetailView.vue')
export const invoiceRoutes: RouteRecordRaw[] = [
{
path: 'invoices',
name: 'invoices.index',
component: InvoiceIndexView,
meta: {
ability: 'view-invoice',
title: 'invoices.title',
},
},
{
path: 'invoices/create',
name: 'invoices.create',
component: InvoiceCreateView,
meta: {
ability: 'create-invoice',
title: 'invoices.new_invoice',
},
},
{
path: 'invoices/:id/edit',
name: 'invoices.edit',
component: InvoiceCreateView,
meta: {
ability: 'edit-invoice',
title: 'invoices.edit_invoice',
},
},
{
path: 'invoices/:id/view',
name: 'invoices.view',
component: InvoiceDetailView,
meta: {
ability: 'view-invoice',
title: 'invoices.title',
},
},
]

View File

@@ -0,0 +1,539 @@
import { defineStore } from 'pinia'
import { invoiceService } from '../../../api/services/invoice.service'
import type {
InvoiceListParams,
InvoiceListResponse,
SendInvoicePayload,
InvoiceStatusPayload,
InvoiceTemplate,
} from '../../../api/services/invoice.service'
import type { Invoice, InvoiceItem, DiscountType } from '../../../types/domain/invoice'
import type { Tax, TaxType } from '../../../types/domain/tax'
import type { Currency } from '../../../types/domain/currency'
import type { Customer } from '../../../types/domain/customer'
import type { Note } from '../../../types/domain/note'
import type { CustomFieldValue } from '../../../types/domain/custom-field'
import type { DocumentTax, DocumentItem } from '../../shared/document-form/use-document-calculations'
// ----------------------------------------------------------------
// Stub factories
// ----------------------------------------------------------------
function createTaxStub(): DocumentTax {
return {
id: crypto.randomUUID(),
name: '',
tax_type_id: 0,
type: 'GENERAL',
amount: 0,
percent: null,
compound_tax: false,
calculation_type: null,
fixed_amount: 0,
}
}
function createInvoiceItemStub(): DocumentItem {
return {
id: crypto.randomUUID(),
invoice_id: null,
item_id: null,
name: '',
description: null,
quantity: 1,
price: 0,
discount_type: 'fixed',
discount_val: 0,
discount: 0,
total: 0,
totalTax: 0,
totalSimpleTax: 0,
totalCompoundTax: 0,
tax: 0,
taxes: [createTaxStub()],
unit_name: null,
}
}
export interface InvoiceFormData {
id: number | null
invoice_number: string
customer: Customer | null
customer_id: number | null
template_name: string | null
invoice_date: string
due_date: string
notes: string | null
discount: number
discount_type: DiscountType
discount_val: number
reference_number: string | null
tax: number
sub_total: number
total: number
tax_per_item: string | null
tax_included: boolean
sales_tax_type: string | null
sales_tax_address_type: string | null
discount_per_item: string | null
taxes: DocumentTax[]
items: DocumentItem[]
customFields: CustomFieldValue[]
fields: CustomFieldValue[]
selectedNote: Note | null
selectedCurrency: Currency | Record<string, unknown> | string
unique_hash?: string
exchange_rate?: number | null
currency_id?: number
}
function createInvoiceStub(): InvoiceFormData {
return {
id: null,
invoice_number: '',
customer: null,
customer_id: null,
template_name: null,
invoice_date: '',
due_date: '',
notes: '',
discount: 0,
discount_type: 'fixed',
discount_val: 0,
reference_number: null,
tax: 0,
sub_total: 0,
total: 0,
tax_per_item: null,
tax_included: false,
sales_tax_type: null,
sales_tax_address_type: null,
discount_per_item: null,
taxes: [],
items: [createInvoiceItemStub()],
customFields: [],
fields: [],
selectedNote: null,
selectedCurrency: '',
}
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface InvoiceState {
templates: InvoiceTemplate[]
invoices: Invoice[]
selectedInvoices: number[]
selectAllField: boolean
invoiceTotalCount: number
showExchangeRate: boolean
isFetchingInitialSettings: boolean
isFetchingInvoice: boolean
newInvoice: InvoiceFormData
}
export const useInvoiceStore = defineStore('invoice', {
state: (): InvoiceState => ({
templates: [],
invoices: [],
selectedInvoices: [],
selectAllField: false,
invoiceTotalCount: 0,
showExchangeRate: false,
isFetchingInitialSettings: false,
isFetchingInvoice: false,
newInvoice: createInvoiceStub(),
}),
getters: {
getInvoice:
(state) =>
(id: number): Invoice | undefined => {
return state.invoices.find((invoice) => invoice.id === id)
},
getSubTotal(state): number {
return state.newInvoice.items.reduce(
(sum: number, item: DocumentItem) => sum + (item.total ?? 0),
0,
)
},
getNetTotal(): number {
return this.getSubtotalWithDiscount - this.getTotalTax
},
getTotalSimpleTax(state): number {
return state.newInvoice.taxes.reduce(
(sum: number, tax: DocumentTax) => {
if (!tax.compound_tax) return sum + (tax.amount ?? 0)
return sum
},
0,
)
},
getTotalCompoundTax(state): number {
return state.newInvoice.taxes.reduce(
(sum: number, tax: DocumentTax) => {
if (tax.compound_tax) return sum + (tax.amount ?? 0)
return sum
},
0,
)
},
getTotalTax(): number {
if (
this.newInvoice.tax_per_item === 'NO' ||
this.newInvoice.tax_per_item === null
) {
return this.getTotalSimpleTax + this.getTotalCompoundTax
}
return this.newInvoice.items.reduce(
(sum: number, item: DocumentItem) => sum + (item.tax ?? 0),
0,
)
},
getSubtotalWithDiscount(): number {
return this.getSubTotal - this.newInvoice.discount_val
},
getTotal(): number {
if (this.newInvoice.tax_included) {
return this.getSubtotalWithDiscount
}
return this.getSubtotalWithDiscount + this.getTotalTax
},
isEdit(state): boolean {
return !!state.newInvoice.id
},
},
actions: {
resetCurrentInvoice(): void {
this.newInvoice = createInvoiceStub()
},
async previewInvoice(params: { id: number }): Promise<unknown> {
return invoiceService.sendPreview(params)
},
async fetchInvoices(
params: InvoiceListParams & { invoice_number?: string },
): Promise<{ data: InvoiceListResponse }> {
const response = await invoiceService.list(params)
this.invoices = response.data
this.invoiceTotalCount = response.meta.invoice_total_count
return { data: response }
},
async fetchInvoice(id: number): Promise<{ data: { data: Invoice } }> {
const response = await invoiceService.get(id)
this.setInvoiceData(response.data)
this.setCustomerAddresses(this.newInvoice.customer)
return { data: response }
},
setInvoiceData(invoice: Invoice): void {
Object.assign(this.newInvoice, invoice)
if (this.newInvoice.tax_per_item === 'YES') {
this.newInvoice.items.forEach((item) => {
if (item.taxes && !item.taxes.length) {
item.taxes.push(createTaxStub())
}
})
}
if (this.newInvoice.discount_per_item === 'YES') {
this.newInvoice.items.forEach((item, index) => {
if (item.discount_type === 'fixed') {
this.newInvoice.items[index].discount = item.discount / 100
}
})
} else {
if (this.newInvoice.discount_type === 'fixed') {
this.newInvoice.discount = this.newInvoice.discount / 100
}
}
},
setCustomerAddresses(customer: Customer | null): void {
if (!customer) return
const business = (customer as Record<string, unknown>).customer_business as
| Record<string, unknown>
| undefined
if (business?.billing_address) {
;(this.newInvoice.customer as Record<string, unknown>).billing_address =
business.billing_address
}
if (business?.shipping_address) {
;(this.newInvoice.customer as Record<string, unknown>).shipping_address =
business.shipping_address
}
},
addSalesTaxUs(taxTypes: TaxType[]): void {
const salesTax = createTaxStub()
const found = this.newInvoice.taxes.find(
(t) => t.name === 'Sales Tax' && t.type === 'MODULE',
)
if (found) {
for (const key in found) {
if (Object.prototype.hasOwnProperty.call(salesTax, key)) {
;(salesTax as Record<string, unknown>)[key] = (
found as Record<string, unknown>
)[key]
}
}
salesTax.id = found.tax_type_id
taxTypes.push(salesTax as unknown as TaxType)
}
},
async sendInvoice(data: SendInvoicePayload): Promise<unknown> {
const response = await invoiceService.send(data)
return response
},
async addInvoice(data: Record<string, unknown>): Promise<{ data: { data: Invoice } }> {
const response = await invoiceService.create(data as never)
this.invoices = [...this.invoices, response.data]
return { data: response }
},
async deleteInvoice(payload: { ids: number[] }): Promise<{ data: { success: boolean } }> {
const response = await invoiceService.delete(payload)
const id = payload.ids[0]
const index = this.invoices.findIndex((inv) => inv.id === id)
if (index !== -1) {
this.invoices.splice(index, 1)
}
return { data: response }
},
async deleteMultipleInvoices(): Promise<{ data: { success: boolean } }> {
const response = await invoiceService.delete({ ids: this.selectedInvoices })
this.selectedInvoices.forEach((invoiceId) => {
const index = this.invoices.findIndex((inv) => inv.id === invoiceId)
if (index !== -1) {
this.invoices.splice(index, 1)
}
})
this.selectedInvoices = []
return { data: response }
},
async updateInvoice(data: Record<string, unknown>): Promise<{ data: { data: Invoice } }> {
const response = await invoiceService.update(data.id as number, data as never)
const pos = this.invoices.findIndex((inv) => inv.id === response.data.id)
if (pos !== -1) {
this.invoices[pos] = response.data
}
return { data: response }
},
async cloneInvoice(data: { id: number }): Promise<{ data: { data: Invoice } }> {
const response = await invoiceService.clone(data.id)
return { data: response }
},
async markAsSent(data: InvoiceStatusPayload): Promise<unknown> {
const response = await invoiceService.changeStatus(data)
const pos = this.invoices.findIndex((inv) => inv.id === data.id)
if (pos !== -1 && this.invoices[pos]) {
this.invoices[pos].status = 'SENT' as Invoice['status']
}
return response
},
async getNextNumber(
params?: Record<string, unknown>,
setState = false,
): Promise<{ data: { nextNumber: string } }> {
const response = await invoiceService.getNextNumber(params as never)
if (setState) {
this.newInvoice.invoice_number = response.nextNumber
}
return { data: response }
},
async searchInvoice(queryString: string): Promise<unknown> {
return invoiceService.list(
Object.fromEntries(new URLSearchParams(queryString)) as never,
)
},
selectInvoice(data: number[]): void {
this.selectedInvoices = data
this.selectAllField =
this.selectedInvoices.length === this.invoices.length
},
selectAllInvoices(): void {
if (this.selectedInvoices.length === this.invoices.length) {
this.selectedInvoices = []
this.selectAllField = false
} else {
this.selectedInvoices = this.invoices.map((inv) => inv.id)
this.selectAllField = true
}
},
async selectCustomer(id: number): Promise<unknown> {
// This would use customerService in a full implementation
const { customerService } = await import(
'../../../api/services/customer.service'
)
const response = await customerService.get(id)
this.newInvoice.customer = response.data as unknown as Customer
this.newInvoice.customer_id = response.data.id
return response
},
async fetchInvoiceTemplates(): Promise<{ data: { invoiceTemplates: InvoiceTemplate[] } }> {
const response = await invoiceService.getTemplates()
this.templates = response.invoiceTemplates
return { data: response }
},
selectNote(data: Note): void {
this.newInvoice.selectedNote = null
this.newInvoice.selectedNote = data
},
setTemplate(name: string): void {
this.newInvoice.template_name = name
},
resetSelectedCustomer(): void {
this.newInvoice.customer = null
this.newInvoice.customer_id = null
},
addItem(): void {
this.newInvoice.items.push(createInvoiceItemStub())
},
updateItem(data: DocumentItem & { index: number }): void {
Object.assign(this.newInvoice.items[data.index], { ...data })
},
removeItem(index: number): void {
this.newInvoice.items.splice(index, 1)
},
deselectItem(index: number): void {
this.newInvoice.items[index] = createInvoiceItemStub()
},
resetSelectedNote(): void {
this.newInvoice.selectedNote = null
},
async fetchInvoiceInitialSettings(
isEdit: boolean,
routeParams?: { id?: string; query?: Record<string, string> },
companySettings?: Record<string, string>,
companyCurrency?: Currency,
userSettings?: Record<string, string>,
): Promise<void> {
this.isFetchingInitialSettings = true
if (companyCurrency) {
this.newInvoice.selectedCurrency = companyCurrency
}
// If customer is specified in route query
if (routeParams?.query?.customer) {
try {
await this.selectCustomer(Number(routeParams.query.customer))
} catch {
// Silently fail
}
}
const editActions: Promise<unknown>[] = []
if (!isEdit && companySettings) {
this.newInvoice.tax_per_item = companySettings.tax_per_item ?? null
this.newInvoice.sales_tax_type = companySettings.sales_tax_type ?? null
this.newInvoice.sales_tax_address_type =
companySettings.sales_tax_address_type ?? null
this.newInvoice.discount_per_item =
companySettings.discount_per_item ?? null
let dateFormat = 'YYYY-MM-DD'
if (companySettings.invoice_use_time === 'YES') {
dateFormat += ' HH:mm'
}
const now = new Date()
this.newInvoice.invoice_date = formatDate(now, dateFormat)
if (companySettings.invoice_set_due_date_automatically === 'YES') {
const dueDate = new Date(now)
dueDate.setDate(
dueDate.getDate() +
Number(companySettings.invoice_due_date_days ?? 7),
)
this.newInvoice.due_date = formatDate(dueDate, 'YYYY-MM-DD')
}
} else if (isEdit && routeParams?.id) {
editActions.push(this.fetchInvoice(Number(routeParams.id)))
}
try {
const [, , templatesRes, nextNumRes] = await Promise.all([
Promise.resolve(), // placeholder for items fetch
this.resetSelectedNote(),
this.fetchInvoiceTemplates(),
this.getNextNumber(),
Promise.resolve(), // placeholder for tax types fetch
...editActions,
])
if (!isEdit) {
if (nextNumRes?.data?.nextNumber) {
this.newInvoice.invoice_number = nextNumRes.data.nextNumber
}
if (templatesRes?.data?.invoiceTemplates?.length) {
this.setTemplate(this.templates[0].name)
if (userSettings?.default_invoice_template) {
this.newInvoice.template_name =
userSettings.default_invoice_template
}
}
}
} catch {
// Error handling
} finally {
this.isFetchingInitialSettings = false
}
},
},
})
/** Simple date formatter without moment dependency */
function formatDate(date: Date, format: string): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
let result = `${year}-${month}-${day}`
if (format.includes('HH:mm')) {
result += ` ${hours}:${minutes}`
}
return result
}
export type InvoiceStore = ReturnType<typeof useInvoiceStore>

View File

@@ -0,0 +1,235 @@
<template>
<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('invoices.invoice', 2)" to="/admin/invoices" />
<BaseBreadcrumbItem
v-if="isEdit"
:title="$t('invoices.edit_invoice')"
to="#"
active
/>
<BaseBreadcrumbItem v-else :title="$t('invoices.new_invoice')" to="#" active />
</BaseBreadcrumb>
<template #actions>
<router-link
v-if="isEdit"
: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="ArrowDownOnSquareIcon"
: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 -->
<DocumentItemsTable
: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 -->
<DocumentNotes
:store="invoiceStore"
store-prop="newInvoice"
:fields="invoiceNoteFieldList"
type="Invoice"
/>
<!-- Invoice Template Button -->
<TemplateSelectButton
:store="invoiceStore"
store-prop="newInvoice"
:is-mark-as-default="isMarkAsDefault"
/>
</div>
<DocumentTotals
:currency="invoiceStore.newInvoice.selectedCurrency"
:is-loading="isLoadingContent"
:store="invoiceStore"
store-prop="newInvoice"
tax-popup-type="invoice"
/>
</div>
</BaseScrollPane>
</form>
</BasePage>
</template>
<script setup lang="ts">
import { computed, 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 '../store'
import InvoiceBasicFields from '../components/InvoiceBasicFields.vue'
import {
DocumentItemsTable,
DocumentTotals,
DocumentNotes,
TemplateSelectButton,
} from '../../../shared/document-form'
const invoiceStore = useInvoiceStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const invoiceValidationScope = 'newInvoice'
const isSaving = ref<boolean>(false)
const isMarkAsDefault = ref<boolean>(false)
const invoiceNoteFieldList = ref<Record<string, unknown> | null>(null)
const isLoadingContent = computed<boolean>(
() => invoiceStore.isFetchingInvoice || invoiceStore.isFetchingInitialSettings,
)
const pageTitle = computed<string>(() =>
isEdit.value ? t('invoices.edit_invoice') : t('invoices.new_invoice'),
)
const isEdit = computed<boolean>(() => 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(() => {
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 },
)
// Initialization
invoiceStore.resetCurrentInvoice()
v$.value.$reset
invoiceStore.fetchInvoiceInitialSettings(
isEdit.value,
{ id: route.params.id as string, query: route.query as Record<string, string> },
)
watch(
() => invoiceStore.newInvoice.customer,
(newVal) => {
if (newVal && (newVal as Record<string, unknown>).currency) {
invoiceStore.newInvoice.selectedCurrency = (
newVal as Record<string, unknown>
).currency as Record<string, unknown>
}
},
)
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data: Record<string, unknown> = {
...structuredClone(invoiceStore.newInvoice),
sub_total: invoiceStore.getSubTotal,
total: invoiceStore.getTotal,
tax: invoiceStore.getTotalTax,
}
const items = data.items as Array<Record<string, unknown>>
if (data.discount_per_item === 'YES') {
items.forEach((item, index) => {
if (item.discount_type === 'fixed') {
items[index].discount = (item.discount as number) * 100
}
})
} else {
if (data.discount_type === 'fixed') {
data.discount = (data.discount as number) * 100
}
}
const taxes = data.taxes as Array<Record<string, unknown>>
if (data.tax_per_item !== 'YES' && taxes.length) {
data.tax_type_ids = taxes.map((tax) => tax.tax_type_id)
}
try {
const action = isEdit.value
? invoiceStore.updateInvoice
: invoiceStore.addInvoice
const response = await action(data)
router.push(`/admin/invoices/${response.data.data.id}/view`)
} catch (err) {
console.error(err)
}
isSaving.value = false
}
</script>

View File

@@ -0,0 +1,408 @@
<template>
<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' && canEdit"
:disabled="isMarkAsSent"
variant="primary-outline"
@click="onMarkAsSent"
>
{{ $t('invoices.mark_as_sent') }}
</BaseButton>
</div>
<BaseButton
v-if="invoiceData.status === 'DRAFT' && canSend"
variant="primary"
class="text-sm"
@click="onSendInvoice"
>
{{ $t('invoices.send_invoice') }}
</BaseButton>
<!-- Record Payment -->
<router-link
v-if="canCreatePayment"
:to="`/admin/payments/${$route.params.id}/create`"
>
<BaseButton
v-if="invoiceData.status === 'SENT' || invoiceData.status === 'VIEWED'"
variant="primary"
>
{{ $t('invoices.record_payment') }}
</BaseButton>
</router-link>
<!-- Invoice Dropdown -->
<InvoiceDropdown
class="ml-3"
:row="invoiceData"
:load-data="() => loadInvoices()"
:can-edit="canEdit"
:can-view="canView"
:can-create="canCreate"
:can-delete="canDelete"
:can-send="canSend"
/>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-[6.4rem] ml-56 bg-surface xl:ml-64 w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-2 border border-line-default 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="MagnifyingGlassIcon" class="h-5 text-subtle" />
</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="FunnelIcon" />
</BaseButton>
</template>
<div
class="px-2 py-1 pb-2 mb-1 mb-2 text-sm border-b border-line-default 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:model-value="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:model-value="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:model-value="onSearched"
/>
</BaseInputGroup>
</BaseDropdownItem>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="BarsArrowUpIcon" />
<BaseIcon v-else name="BarsArrowDownIcon" />
</BaseButton>
</div>
</div>
<div
ref="invoiceListSection"
class="h-full overflow-y-scroll border-l border-line-default border-solid base-scroll"
>
<div v-for="(invoice, index) in invoiceList" :key="index">
<router-link
v-if="invoice"
:id="'invoice-' + invoice.id"
:to="`/admin/invoices/${invoice.id}/view`"
:class="[
'flex justify-between side-invoice p-4 cursor-pointer hover:bg-hover-strong items-center border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-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 ?? ''"
class="pr-2 mb-2 text-sm not-italic font-normal leading-5 text-heading capitalize truncate"
/>
<div
class="mt-1 mb-2 text-xs not-italic font-medium leading-5 text-body"
>
{{ invoice.invoice_number }}
</div>
<BaseEstimateStatusBadge
:status="invoice.status"
class="px-1 text-xs"
>
<BaseInvoiceStatusLabel :status="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-heading block"
:amount="invoice.total"
:currency="invoice.customer?.currency"
/>
<div
class="text-sm not-italic font-normal leading-5 text-right text-body est-date"
>
{{ invoice.formatted_invoice_date }}
</div>
</div>
</router-link>
</div>
<div v-if="isLoading" class="flex justify-center p-4 items-center">
<LoadingIcon class="h-6 m-1 animate-spin text-primary-400" />
</div>
<p
v-if="!invoiceList?.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-body"
>
{{ $t('invoices.no_matching_invoices') }}
</p>
</div>
</div>
<!-- PDF Preview -->
<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-surface rounded-xl frame-style"
/>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useInvoiceStore } from '../store'
import InvoiceDropdown from '../components/InvoiceDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import type { Invoice } from '../../../../types/domain/invoice'
interface Props {
canEdit?: boolean
canView?: boolean
canCreate?: boolean
canDelete?: boolean
canSend?: boolean
canCreatePayment?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canView: false,
canCreate: false,
canDelete: false,
canSend: false,
canCreatePayment: false,
})
const invoiceStore = useInvoiceStore()
const { t } = useI18n()
const route = useRoute()
const invoiceData = ref<Invoice | null>(null)
const isMarkAsSent = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const invoiceList = ref<Invoice[] | null>(null)
const currentPageNumber = ref<number>(1)
const lastPageNumber = ref<number>(1)
const invoiceListSection = ref<HTMLElement | null>(null)
interface SearchData {
orderBy: string | null
orderByField: string | null
searchText: string | null
}
const searchData = reactive<SearchData>({
orderBy: null,
orderByField: null,
searchText: null,
})
const pageTitle = computed<string>(() => invoiceData.value?.invoice_number ?? '')
const getOrderBy = computed<boolean>(() => {
return searchData.orderBy === 'asc' || searchData.orderBy === null
})
const shareableLink = computed<string>(() => {
return `/invoices/pdf/${invoiceData.value?.unique_hash ?? ''}`
})
watch(route, (to) => {
if (to.name === 'invoices.view') {
loadInvoice()
}
})
async function onMarkAsSent(): Promise<void> {
const confirmed = window.confirm(t('invoices.invoice_mark_as_sent'))
if (!confirmed) return
isMarkAsSent.value = false
await invoiceStore.markAsSent({
id: invoiceData.value!.id,
status: 'SENT',
})
invoiceData.value!.status = 'SENT' as Invoice['status']
isMarkAsSent.value = true
isMarkAsSent.value = false
}
function onSendInvoice(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('invoices.send_invoice'),
componentName: 'SendInvoiceModal',
id: invoiceData.value!.id,
data: invoiceData.value,
})
}
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadInvoices(
pageNumber?: number,
fromScrollListener = false,
): Promise<void> {
if (isLoading.value) return
const params: Record<string, unknown> = {}
if (searchData.searchText) {
params.search = searchData.searchText
}
if (searchData.orderBy != null) {
params.orderBy = searchData.orderBy
}
if (searchData.orderByField != null) {
params.orderByField = searchData.orderByField
}
isLoading.value = true
const response = await invoiceStore.fetchInvoices({
page: pageNumber,
...params,
} as never)
isLoading.value = false
invoiceList.value = invoiceList.value ?? []
invoiceList.value = [...invoiceList.value, ...response.data.data]
currentPageNumber.value = pageNumber ?? 1
lastPageNumber.value = response.data.meta.last_page
const invoiceFound = invoiceList.value.find(
(inv) => inv.id === Number(route.params.id),
)
if (
!fromScrollListener &&
!invoiceFound &&
currentPageNumber.value < lastPageNumber.value &&
Object.keys(params).length === 0
) {
loadInvoices(++currentPageNumber.value)
}
if (invoiceFound && !fromScrollListener) {
setTimeout(() => scrollToInvoice(), 500)
}
}
function scrollToInvoice(): void {
const el = document.getElementById(`invoice-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
addScrollListener()
}
}
function addScrollListener(): void {
invoiceListSection.value?.addEventListener('scroll', (ev) => {
const target = ev.target as HTMLElement
if (
target.scrollTop > 0 &&
target.scrollTop + target.clientHeight > target.scrollHeight - 200
) {
if (currentPageNumber.value < lastPageNumber.value) {
loadInvoices(++currentPageNumber.value, true)
}
}
})
}
async function loadInvoice(): Promise<void> {
const response = await invoiceStore.fetchInvoice(Number(route.params.id))
if (response.data) {
invoiceData.value = { ...response.data.data } as Invoice
}
}
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function onSearched(): void {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
invoiceList.value = []
loadInvoices()
}, 500)
}
function sortData(): void {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
} else {
searchData.orderBy = 'asc'
}
onSearched()
}
// Initialize
loadInvoices()
loadInvoice()
</script>

View File

@@ -0,0 +1,514 @@
<template>
<BasePage>
<BasePageHeader :title="$t('invoices.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('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="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link v-if="canCreate" 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>
<!-- Filters -->
<BaseFilterWrapper
v-show="showFilters"
:row-on-xl="true"
@clear="clearFilter"
>
<BaseInputGroup :label="$t('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="statusOptions"
searchable
:placeholder="$t('general.select_a_status')"
@update:model-value="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>
<!-- Empty State -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('invoices.no_invoices')"
:description="$t('invoices.list_of_invoices')"
>
<template v-if="canCreate" #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>
<!-- Table -->
<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-line-default border-solid"
>
<BaseTabGroup class="-mb-5" @change="setStatusFilter">
<BaseTab :title="$t('general.all')" filter="" />
<BaseTab :title="$t('general.draft')" filter="DRAFT" />
<BaseTab :title="$t('general.sent')" filter="SENT" />
<BaseTab :title="$t('general.due')" filter="DUE" />
</BaseTabGroup>
<BaseDropdown
v-if="invoiceStore.selectedInvoices.length && canDelete"
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-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="invoiceColumns"
:placeholder-count="invoiceStore.invoiceTotalCount >= 20 ? 10 : 5"
:key="tableKey"
class="mt-10"
>
<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" />
</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-invoice_date="{ row }">
{{ row.data.formatted_invoice_date }}
</template>
<template #cell-total="{ 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">
<BaseInvoiceStatusLabel :status="row.data.status" />
</BaseInvoiceStatusBadge>
</template>
<template #cell-due_amount="{ row }">
<div class="flex justify-between">
<BaseFormatMoney
:amount="row.data.due_amount"
:currency="row.data.currency"
/>
<BasePaidStatusBadge
v-if="row.data.overdue"
status="OVERDUE"
class="px-1 py-0.5 ml-2"
>
{{ $t('invoices.overdue') }}
</BasePaidStatusBadge>
<BasePaidStatusBadge
:status="row.data.paid_status"
class="px-1 py-0.5 ml-2"
>
<BaseInvoiceStatusLabel :status="row.data.paid_status" />
</BasePaidStatusBadge>
</div>
</template>
<template v-if="hasAtLeastOneAbility" #cell-actions="{ row }">
<InvoiceDropdown
:row="row.data"
:table="tableRef"
:can-edit="canEdit"
:can-view="canView"
:can-create="canCreate"
:can-delete="canDelete"
:can-send="canSend"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useInvoiceStore } from '../store'
import InvoiceDropdown from '../components/InvoiceDropdown.vue'
import type { Invoice } from '../../../../types/domain/invoice'
interface Props {
canCreate?: boolean
canEdit?: boolean
canView?: boolean
canDelete?: boolean
canSend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canCreate: false,
canEdit: false,
canView: false,
canDelete: false,
canSend: false,
})
const invoiceStore = useInvoiceStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const tableKey = ref<number>(0)
const showFilters = ref<boolean>(false)
const isRequestOngoing = ref<boolean>(true)
const activeTab = ref<string>('general.draft')
interface StatusOption {
label: string
value: string
}
interface StatusGroup {
label: string
options: StatusOption[]
}
const statusOptions = ref<StatusGroup[]>([
{
label: t('invoices.status'),
options: [
{ label: t('general.draft'), value: 'DRAFT' },
{ label: t('general.due'), value: 'DUE' },
{ label: t('general.sent'), value: 'SENT' },
{ label: t('invoices.viewed'), value: 'VIEWED' },
{ label: t('invoices.completed'), value: 'COMPLETED' },
],
},
{
label: t('invoices.paid_status'),
options: [
{ label: t('invoices.unpaid'), value: 'UNPAID' },
{ label: t('invoices.paid'), value: 'PAID' },
{ label: t('invoices.partially_paid'), value: 'PARTIALLY_PAID' },
],
},
])
interface InvoiceFilters {
customer_id: string | number
status: string
from_date: string
to_date: string
invoice_number: string
}
const filters = reactive<InvoiceFilters>({
customer_id: '',
status: '',
from_date: '',
to_date: '',
invoice_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !invoiceStore.invoiceTotalCount && !isRequestOngoing.value,
)
const selectField = computed<number[]>({
get: () => invoiceStore.selectedInvoices,
set: (value: number[]) => {
invoiceStore.selectInvoice(value)
},
})
const hasAtLeastOneAbility = computed<boolean>(() => {
return props.canDelete || props.canEdit || props.canView || props.canSend
})
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
placeholderClass?: string
sortable?: boolean
}
const invoiceColumns = computed<TableColumn[]>(() => [
{
key: 'checkbox',
thClass: 'extra w-10',
tdClass: 'font-medium text-heading',
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-heading',
},
{
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 clearStatusSearch(): void {
filters.status = ''
refreshTable()
}
function refreshTable(): void {
tableRef.value?.refresh()
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: Invoice[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
customer_id: filters.customer_id ? Number(filters.customer_id) : undefined,
status: filters.status || undefined,
from_date: filters.from_date || undefined,
to_date: filters.to_date || undefined,
invoice_number: filters.invoice_number || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: (sort.order || 'desc') as 'asc' | 'desc',
page,
}
isRequestOngoing.value = true
const 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: { title: string }): void {
if (activeTab.value === val.title) return
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(): void {
invoiceStore.$patch((state) => {
state.selectedInvoices = []
state.selectAllField = false
})
tableKey.value += 1
refreshTable()
}
function clearFilter(): void {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.invoice_number = ''
activeTab.value = t('general.all')
}
async function removeMultipleInvoices(): Promise<void> {
const confirmed = window.confirm(t('invoices.confirm_delete'))
if (!confirmed) return
const res = await invoiceStore.deleteMultipleInvoices()
if (res.data.success) {
refreshTable()
invoiceStore.$patch((state) => {
state.selectedInvoices = []
state.selectAllField = false
})
}
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function setActiveTab(val: string): void {
const tabMap: Record<string, string> = {
DRAFT: t('general.draft'),
SENT: t('general.sent'),
DUE: t('general.due'),
COMPLETED: t('invoices.completed'),
PAID: t('invoices.paid'),
UNPAID: t('invoices.unpaid'),
PARTIALLY_PAID: t('invoices.partially_paid'),
VIEWED: t('invoices.viewed'),
}
activeTab.value = tabMap[val] ?? t('general.all')
}
</script>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useItemStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { useUserStore } from '../../../../stores/user.store'
interface RowData {
id: number
[key: string]: unknown
}
interface Props {
row: RowData | null
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}
const ABILITIES = {
EDIT_ITEM: 'edit-item',
DELETE_ITEM: 'delete-item',
} as const
const props = withDefaults(defineProps<Props>(), {
row: null,
table: null,
loadData: null,
})
const dialogStore = useDialogStore()
const { t } = useI18n()
const itemStore = useItemStore()
const route = useRoute()
const userStore = useUserStore()
function removeItem(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('items.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res: boolean) => {
if (res) {
itemStore.deleteItem({ ids: [id] }).then((response) => {
if (response.success) {
props.loadData?.()
}
})
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'items.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Item -->
<router-link
v-if="userStore.hasAbilities(ABILITIES.EDIT_ITEM) && row"
:to="`/admin/items/${row.id}/edit`"
>
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<!-- Delete Item -->
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_ITEM) && row"
@click="removeItem(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '../../../../stores/modal.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useItemStore } from '../store'
// Tax type store - imported from original location
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
interface TaxOption {
id: number
name: string
percent: number
fixed_amount: number
calculation_type: string | null
tax_name: string
}
interface Emits {
(e: 'newItem', item: unknown): void
}
const emit = defineEmits<Emits>()
const modalStore = useModalStore()
const itemStore = useItemStore()
const companyStore = useCompanyStore()
const taxTypeStore = useTaxTypeStore()
const { t } = useI18n()
const isLoading = ref<boolean>(false)
const taxPerItemSetting = ref<string>(
companyStore.selectedCompanySettings.tax_per_item || 'NO'
)
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'ItemModal'
)
const price = computed<number>({
get: () => itemStore.currentItem.price / 100,
set: (value: number) => {
itemStore.currentItem.price = Math.round(value * 100)
},
})
const taxes = computed({
get: () =>
itemStore.currentItem.taxes.map((tax) => {
if (tax) {
const currencySymbol = companyStore.selectedCompanyCurrency?.symbol ?? '$'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? tax.fixed_amount + currencySymbol
: tax.percent + '%'
})`,
}
}
return tax
}),
set: (value: TaxOption[]) => {
itemStore.currentItem.taxes = value as unknown as typeof itemStore.currentItem.taxes
},
})
const isTaxPerItemEnabled = computed<boolean>(() => {
return taxPerItemSetting.value === 'YES'
})
const rules = {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength', { count: 255 }),
maxLength(255)
),
},
}
const v$ = useVuelidate(
rules,
computed(() => itemStore.currentItem)
)
const getTaxTypes = computed<TaxOption[]>(() => {
return taxTypeStore.taxTypes.map((tax) => {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
const amount =
tax.calculation_type === 'fixed'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
}).format(tax.fixed_amount / 100)
: `${tax.percent}%`
return {
...tax,
tax_name: `${tax.name} (${amount})`,
}
}) as TaxOption[]
})
onMounted(() => {
v$.value.$reset()
itemStore.fetchItemUnits({ limit: 'all' })
})
async function submitItemData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
const data: Record<string, unknown> = {
...itemStore.currentItem,
taxes: itemStore.currentItem.taxes.map((tax) => ({
tax_type_id: tax.id,
amount:
tax.calculation_type === 'fixed'
? tax.fixed_amount
: Math.round(price.value * tax.percent),
percent: tax.percent,
fixed_amount: tax.fixed_amount,
calculation_type: tax.calculation_type,
name: tax.name,
collective_tax: 0,
})),
}
isLoading.value = true
const action = itemStore.isEdit ? itemStore.updateItem : itemStore.addItem
try {
const res = await action(data)
isLoading.value = false
if (res.data) {
if (modalStore.refreshData) {
modalStore.refreshData(res.data)
}
}
closeItemModal()
} catch {
isLoading.value = false
}
}
function closeItemModal(): void {
modalStore.closeModal()
setTimeout(() => {
itemStore.resetCurrentItem()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @close="closeItemModal">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeItemModal"
/>
</div>
</template>
<div class="item-modal">
<form action="" @submit.prevent="submitItemData">
<div class="px-8 py-8 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('items.name')"
required
:error="v$.name.$error && v$.name.$errors[0].$message"
>
<BaseInput
v-model="itemStore.currentItem.name"
type="text"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('items.price')">
<BaseMoney
:key="companyStore.selectedCompanyCurrency?.id"
v-model="price"
:currency="companyStore.selectedCompanyCurrency"
class="
relative
w-full
focus:border focus:border-solid focus:border-primary
"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('items.unit')">
<BaseMultiselect
v-model="itemStore.currentItem.unit_id"
label="name"
:options="itemStore.itemUnits"
value-prop="id"
:can-deselect="false"
:can-clear="false"
:placeholder="$t('items.select_a_unit')"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isTaxPerItemEnabled"
:label="$t('items.taxes')"
>
<BaseMultiselect
v-model="taxes"
:options="getTaxTypes"
mode="tags"
label="tax_name"
value-prop="id"
class="w-full"
:can-deselect="false"
:can-clear="false"
searchable
track-by="tax_name"
object
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('items.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
>
<BaseTextarea
v-model="itemStore.currentItem.description"
rows="4"
cols="50"
:invalid="v$.description.$error"
@input="v$.description.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
class="mr-3"
variant="primary-outline"
type="button"
@click="closeItemModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isLoading"
:disabled="isLoading"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ itemStore.isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</div>
</BaseModal>
</template>

View File

@@ -0,0 +1,6 @@
export { useItemStore } from './store'
export type {
ItemForm,
ItemUnitForm,
} from './store'
export { default as itemRoutes } from './routes'

View File

@@ -0,0 +1,30 @@
import type { RouteRecordRaw } from 'vue-router'
const itemRoutes: RouteRecordRaw[] = [
{
path: 'items',
name: 'items.index',
component: () => import('./views/ItemIndexView.vue'),
meta: {
ability: 'view-item',
},
},
{
path: 'items/create',
name: 'items.create',
component: () => import('./views/ItemCreateView.vue'),
meta: {
ability: 'create-item',
},
},
{
path: 'items/:id/edit',
name: 'items.edit',
component: () => import('./views/ItemCreateView.vue'),
meta: {
ability: 'edit-item',
},
},
]
export default itemRoutes

View File

@@ -0,0 +1,316 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { itemService } from '../../../api/services/item.service'
import type {
ItemListParams,
ItemListResponse,
CreateItemPayload,
CreateUnitPayload,
} from '../../../api/services/item.service'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
import type { Item, Unit } from '../../../types/domain/item'
import type { Tax } from '../../../types/domain/tax'
import type { ApiResponse, DeletePayload } from '../../../types/api'
export interface ItemForm {
id?: number
name: string
description: string
price: number
unit_id: string | number | null
unit: Unit | null
taxes: Tax[]
tax_per_item?: boolean | number | string
}
export interface ItemUnitForm {
id: number | null
name: string
}
function createItemStub(): ItemForm {
return {
name: '',
description: '',
price: 0,
unit_id: '',
unit: null,
taxes: [],
tax_per_item: false,
}
}
export const useItemStore = defineStore('item', () => {
// State
const items = ref<Item[]>([])
const totalItems = ref<number>(0)
const selectAllField = ref<boolean>(false)
const selectedItems = ref<number[]>([])
const itemUnits = ref<Unit[]>([])
const currentItemUnit = ref<ItemUnitForm>({
id: null,
name: '',
})
const currentItem = ref<ItemForm>(createItemStub())
// Getters
const isEdit = computed<boolean>(() => !!currentItem.value.id)
const isItemUnitEdit = computed<boolean>(() => !!currentItemUnit.value.id)
// Actions
function resetCurrentItem(): void {
currentItem.value = createItemStub()
}
async function fetchItems(params?: ItemListParams): Promise<ItemListResponse> {
try {
const response = await itemService.list(params)
items.value = response.data
totalItems.value = response.meta.item_total_count
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchItem(id: number): Promise<ApiResponse<Item>> {
try {
const response = await itemService.get(id)
if (response.data) {
Object.assign(currentItem.value, response.data)
}
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function addItem(data: Record<string, unknown>): Promise<ApiResponse<Item>> {
try {
const response = await itemService.create(data as unknown as CreateItemPayload)
items.value.push(response.data)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'items.created_message',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateItem(data: Record<string, unknown>): Promise<ApiResponse<Item>> {
try {
const id = data.id as number
const response = await itemService.update(id, data as unknown as Partial<CreateItemPayload>)
if (response.data) {
const pos = items.value.findIndex(
(item) => item.id === response.data.id
)
if (pos !== -1) {
items.value[pos] = response.data
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'items.updated_message',
})
}
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteItem(payload: DeletePayload): Promise<{ success: boolean }> {
try {
const response = await itemService.delete(payload)
const index = items.value.findIndex(
(item) => item.id === payload.ids[0]
)
if (index !== -1) {
items.value.splice(index, 1)
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'items.deleted_message',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteMultipleItems(): Promise<{ success: boolean }> {
try {
const response = await itemService.delete({ ids: selectedItems.value })
selectedItems.value.forEach((itemId) => {
const index = items.value.findIndex((_item) => _item.id === itemId)
if (index !== -1) {
items.value.splice(index, 1)
}
})
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'items.deleted_message',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
function selectItem(data: number[]): void {
selectedItems.value = data
selectAllField.value = selectedItems.value.length === items.value.length
}
function selectAllItems(): void {
if (selectedItems.value.length === items.value.length) {
selectedItems.value = []
selectAllField.value = false
} else {
selectedItems.value = items.value.map((item) => item.id)
selectAllField.value = true
}
}
async function addItemUnit(data: CreateUnitPayload): Promise<ApiResponse<Unit>> {
try {
const response = await itemService.createUnit(data)
itemUnits.value.push(response.data)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'settings.customization.items.item_unit_added',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateItemUnit(data: { id: number; name: string }): Promise<ApiResponse<Unit>> {
try {
const response = await itemService.updateUnit(data.id, { name: data.name })
const pos = itemUnits.value.findIndex(
(unit) => unit.id === response.data.id
)
if (pos !== -1) {
itemUnits.value[pos] = response.data
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'settings.customization.items.item_unit_updated',
})
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchItemUnits(params?: { limit?: string | number; search?: string }): Promise<{ data: { data: Unit[] } }> {
try {
const response = await itemService.listUnits(params)
itemUnits.value = response.data as unknown as Unit[]
return { data: { data: response.data as unknown as Unit[] } }
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchItemUnit(id: number): Promise<ApiResponse<Unit>> {
try {
const response = await itemService.getUnit(id)
currentItemUnit.value = {
id: response.data.id,
name: response.data.name,
}
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteItemUnit(id: number): Promise<{ success: boolean }> {
try {
const response = await itemService.deleteUnit(id)
if (response.success) {
const index = itemUnits.value.findIndex((unit) => unit.id === id)
if (index !== -1) {
itemUnits.value.splice(index, 1)
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'settings.customization.items.deleted_message',
})
}
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
return {
items,
totalItems,
selectAllField,
selectedItems,
itemUnits,
currentItemUnit,
currentItem,
isEdit,
isItemUnitEdit,
resetCurrentItem,
fetchItems,
fetchItem,
addItem,
updateItem,
deleteItem,
deleteMultipleItems,
selectItem,
selectAllItems,
addItemUnit,
updateItemUnit,
fetchItemUnits,
fetchItemUnit,
deleteItemUnit,
}
})

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useItemStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import ItemUnitModal from '@/scripts/admin/components/modal-components/ItemUnitModal.vue'
// Tax type store - imported from original location
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
interface TaxOption {
id: number
name: string
percent: number
fixed_amount: number
calculation_type: string | null
tax_type_id: number
tax_name: string
}
const ABILITIES = {
VIEW_TAX_TYPE: 'view-tax-type',
} as const
const itemStore = useItemStore()
const taxTypeStore = useTaxTypeStore()
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isSaving = ref<boolean>(false)
const taxPerItem = ref<string>(companyStore.selectedCompanySettings.tax_per_item || 'NO')
const isFetchingInitialData = ref<boolean>(false)
itemStore.resetCurrentItem()
loadData()
const price = computed<number>({
get: () => itemStore.currentItem.price / 100,
set: (value: number) => {
itemStore.currentItem.price = Math.round(value * 100)
},
})
const taxes = computed({
get: () =>
itemStore.currentItem.taxes?.map((tax) => {
if (tax) {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
}).format(tax.fixed_amount / 100)
: `${tax.percent}%`
})`,
}
}
return tax
}) ?? [],
set: (value: TaxOption[]) => {
itemStore.currentItem.taxes = value as unknown as typeof itemStore.currentItem.taxes
},
})
const isEdit = computed<boolean>(() => route.name === 'items.edit')
const pageTitle = computed<string>(() =>
isEdit.value ? t('items.edit_item') : t('items.new_item')
)
const getTaxTypes = computed<TaxOption[]>(() => {
return taxTypeStore.taxTypes.map((tax) => {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
}).format(tax.fixed_amount / 100)
: `${tax.percent}%`
})`,
}
}) as TaxOption[]
})
const isTaxPerItem = computed<boolean>(() => taxPerItem.value === 'YES')
const rules = computed(() => ({
currentItem: {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 2 }),
minLength(2)
),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength'),
maxLength(65000)
),
},
},
}))
const v$ = useVuelidate(rules, itemStore)
async function addItemUnit(): Promise<void> {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
size: 'sm',
})
}
async function loadData(): Promise<void> {
isFetchingInitialData.value = true
await itemStore.fetchItemUnits({ limit: 'all' })
if (userStore.hasAbilities(ABILITIES.VIEW_TAX_TYPE)) {
await taxTypeStore.fetchTaxTypes({ limit: 'all' })
}
if (isEdit.value) {
const id = Number(route.params.id)
await itemStore.fetchItem(id)
taxPerItem.value =
itemStore.currentItem.tax_per_item === 1 ||
itemStore.currentItem.tax_per_item === '1' ||
itemStore.currentItem.tax_per_item === true
? 'YES'
: 'NO'
}
isFetchingInitialData.value = false
}
async function submitItem(): Promise<void> {
v$.value.currentItem.$touch()
if (v$.value.currentItem.$invalid) {
return
}
isSaving.value = true
try {
const data: Record<string, unknown> = {
id: route.params.id,
...itemStore.currentItem,
}
if (itemStore.currentItem.taxes) {
data.taxes = itemStore.currentItem.taxes.map((tax) => ({
tax_type_id: (tax as Record<string, unknown>).tax_type_id ?? tax.id,
calculation_type: tax.calculation_type,
fixed_amount: tax.fixed_amount,
amount:
tax.calculation_type === 'fixed'
? tax.fixed_amount
: Math.round(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')
} catch {
isSaving.value = false
}
}
</script>
<template>
<BasePage>
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('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"
: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="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('items.update_item') : $t('items.save_item') }}
</BaseButton>
</div>
</BaseInputGrid>
</BaseCard>
</form>
</BasePage>
</template>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { ref, computed, reactive, onUnmounted } from 'vue'
import { debouncedWatch } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useItemStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import ItemDropdown from '../components/ItemDropdown.vue'
import SatelliteIcon from '@/scripts/components/icons/empty/SatelliteIcon.vue'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
placeholderClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface FetchResult {
data: unknown[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
interface ItemFilters {
name: string
unit_id: string | number
price: string | number
}
const ABILITIES = {
CREATE_ITEM: 'create-item',
DELETE_ITEM: 'delete-item',
EDIT_ITEM: 'edit-item',
} as const
const itemStore = useItemStore()
const companyStore = useCompanyStore()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const { t } = useI18n()
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const filters = reactive<ItemFilters>({
name: '',
unit_id: '',
price: '',
})
const table = ref<{ refresh: () => void } | null>(null)
const showEmptyScreen = computed<boolean>(
() => !itemStore.totalItems && !isFetchingInitialData.value
)
const selectField = computed<number[]>({
get: () => itemStore.selectedItems,
set: (value: number[]) => {
itemStore.selectItem(value)
},
})
const itemColumns = computed<TableColumn[]>(() => [
{
key: 'status',
thClass: 'extra w-10',
tdClass: 'font-medium text-heading',
placeholderClass: 'w-10',
sortable: false,
},
{
key: 'name',
label: t('items.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ 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(): void {
filters.name = ''
filters.unit_id = ''
filters.price = ''
}
function hasAbilities(): boolean {
return userStore.hasAbilities([ABILITIES.DELETE_ITEM, ABILITIES.EDIT_ITEM])
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function refreshTable(): void {
table.value?.refresh()
}
function setFilters(): void {
refreshTable()
}
async function searchUnits(search: string): Promise<unknown[]> {
const res = await itemStore.fetchItemUnits({ search })
return res.data.data
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
search: filters.name,
unit_id: filters.unit_id !== null ? filters.unit_id : '',
price: Math.round(Number(filters.price) * 100),
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await itemStore.fetchItems(data)
isFetchingInitialData.value = false
return {
data: response.data,
pagination: {
totalPages: response.meta.last_page,
currentPage: page,
totalCount: response.meta.total,
limit: 10,
},
}
}
function removeMultipleItems(): void {
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: boolean) => {
if (res) {
itemStore.deleteMultipleItems().then((response) => {
if (response.success) {
table.value?.refresh()
}
})
}
})
}
</script>
<template>
<BasePage>
<BasePageHeader :title="$t('items.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('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="FunnelIcon"
/>
<BaseIcon v-else name="XMarkIcon" :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="$t('items.name')" class="text-left">
<BaseInput
v-model="filters.name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('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="$t('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-line-default 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-body" />
{{ $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"
>
<BaseText :text="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>

View File

@@ -0,0 +1,434 @@
<template>
<tr class="box-border bg-surface border-b border-line-light">
<td colspan="5" class="p-0 text-left align-top">
<table class="w-full">
<colgroup>
<col style="width: 40%; min-width: 280px" />
<col style="width: 10%; min-width: 120px" />
<col style="width: 15%; min-width: 120px" />
<col
v-if="formData.discount_per_item === 'YES'"
style="width: 15%; min-width: 160px"
/>
<col style="width: 15%; min-width: 120px" />
</colgroup>
<tbody>
<tr>
<!-- Item Name + Description -->
<td class="px-5 py-4 text-left align-top">
<div class="flex justify-start">
<div
class="flex items-center justify-center w-5 h-5 mt-2 mr-2 text-subtle cursor-move handle"
>
<DragIcon />
</div>
<BaseItemSelect
type="Invoice"
:item="itemData"
:invalid="v$.name.$error"
:invalid-description="v$.description.$error"
:taxes="itemData.taxes"
:index="index"
:store-prop="storeProp"
:store="store"
@search="searchVal"
@select="onSelectItem"
/>
</div>
</td>
<!-- Quantity -->
<td class="px-5 py-4 text-right align-top">
<BaseInput
v-model="quantity"
:invalid="v$.quantity.$error"
:content-loading="loading"
type="number"
small
step="any"
@change="syncItemToStore()"
@input="v$.quantity.$touch()"
/>
</td>
<!-- Price -->
<td class="px-5 py-4 text-left align-top">
<div class="flex flex-col">
<div class="flex-auto flex-fill bd-highlight">
<div class="relative w-full">
<BaseMoney
:key="selectedCurrency?.id ?? 'default'"
v-model="price"
:invalid="v$.price.$error"
:content-loading="loading"
:currency="selectedCurrency"
/>
</div>
</div>
</div>
</td>
<!-- Discount -->
<td
v-if="formData.discount_per_item === 'YES'"
class="px-5 py-4 text-left align-top"
>
<div class="flex flex-col">
<div class="flex" style="width: 120px" role="group">
<BaseInput
v-model="discount"
:invalid="v$.discount_val.$error"
:content-loading="loading"
class="border-r-0 focus:border-r-2 rounded-tr-sm rounded-br-sm h-[38px]"
/>
<BaseDropdown position="bottom-end">
<template #activator>
<BaseButton
:content-loading="loading"
class="rounded-tr-md rounded-br-md !p-2 rounded-none"
type="button"
variant="white"
>
<span class="flex items-center">
{{
itemData.discount_type === 'fixed'
? currencySymbol
: '%'
}}
<BaseIcon
name="ChevronDownIcon"
class="w-4 h-4 ml-1 text-muted"
/>
</span>
</BaseButton>
</template>
<BaseDropdownItem @click="selectFixed">
{{ $t('general.fixed') }}
</BaseDropdownItem>
<BaseDropdownItem @click="selectPercentage">
{{ $t('general.percentage') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
</div>
</td>
<!-- Amount -->
<td class="px-5 py-4 text-right align-top">
<div class="flex items-center justify-end text-sm">
<span>
<BaseContentPlaceholders v-if="loading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<BaseFormatMoney
v-else
:amount="total"
:currency="selectedCurrency"
/>
</span>
<div class="flex items-center justify-center w-6 h-10 mx-2">
<BaseIcon
v-if="showRemoveButton"
class="h-5 text-body cursor-pointer"
name="TrashIcon"
@click="store.removeItem(index)"
/>
</div>
</div>
</td>
</tr>
<!-- Per-item taxes -->
<tr v-if="formData.tax_per_item === 'YES'">
<td class="px-5 py-4 text-left align-top" />
<td colspan="4" class="px-5 py-4 text-left align-top">
<BaseContentPlaceholders v-if="loading">
<BaseContentPlaceholdersText
:lines="1"
class="w-24 h-8 border border-line-light rounded-md"
/>
</BaseContentPlaceholders>
<DocumentItemRowTax
v-for="(tax, taxIndex) in itemData.taxes"
v-else
:key="tax.id"
:index="taxIndex"
:item-index="index"
:tax-data="tax"
:taxes="itemData.taxes ?? []"
:discounted-total="total"
:total-tax="totalSimpleTax"
:total="subtotal"
:currency="currency"
:update-items="syncItemToStore"
:ability="'create-invoice'"
:store="store"
:store-prop="storeProp"
:discount="discount"
@update="updateTax"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, between, maxLength, helpers, minValue } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import DocumentItemRowTax from './DocumentItemRowTax.vue'
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentItem, DocumentFormData, DocumentTax } from './use-document-calculations'
interface Props {
store: Record<string, unknown> & {
removeItem: (index: number) => void
updateItem: (data: Record<string, unknown>) => void
$patch: (fn: (state: Record<string, unknown>) => void) => void
}
storeProp: string
itemData: DocumentItem
index: number
type?: string
loading?: boolean
currency: Currency | Record<string, unknown>
invoiceItems: DocumentItem[]
itemValidationScope?: string
}
interface Emits {
(e: 'update', data: Record<string, unknown>): void
(e: 'remove', index: number): void
(e: 'itemValidate', valid: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
type: '',
loading: false,
itemValidationScope: '',
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const currencySymbol = computed<string>(() => {
const curr = props.currency as Record<string, unknown>
return (curr?.symbol as string) ?? '$'
})
const quantity = computed<number>({
get: () => props.itemData.quantity,
set: (newValue: number) => {
updateItemAttribute('quantity', parseFloat(String(newValue)))
},
})
const price = computed<number>({
get: () => props.itemData.price / 100,
set: (newValue: number) => {
const priceInCents = Math.round(newValue * 100)
updateItemAttribute('price', priceInCents)
setDiscount()
},
})
const subtotal = computed<number>(() => {
return Math.round(props.itemData.price * props.itemData.quantity)
})
const discount = computed<number>({
get: () => props.itemData.discount,
set: (newValue: number) => {
updateItemAttribute('discount', newValue)
setDiscount()
},
})
const total = computed<number>(() => {
return subtotal.value - props.itemData.discount_val
})
const selectedCurrency = computed(() => {
if (props.currency) {
return props.currency
}
return null
})
const showRemoveButton = computed<boolean>(() => {
return formData.value.items.length > 1
})
const totalSimpleTax = computed<number>(() => {
const taxes = props.itemData.taxes ?? []
return Math.round(
taxes.reduce((sum: number, tax: Partial<DocumentTax>) => {
return sum + (tax.amount ?? 0)
}, 0),
)
})
const totalTax = computed<number>(() => totalSimpleTax.value)
const rules = {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
quantity: {
required: helpers.withMessage(t('validation.required'), required),
maxLength: helpers.withMessage(t('validation.amount_maxlength'), maxLength(20)),
},
price: {
required: helpers.withMessage(t('validation.required'), required),
maxLength: helpers.withMessage(t('validation.price_maxlength'), maxLength(20)),
},
discount_val: {
between: helpers.withMessage(
t('validation.discount_maxlength'),
between(
0,
computed(() => Math.abs(subtotal.value)),
),
),
},
description: {
maxLength: helpers.withMessage(t('validation.notes_maxlength'), maxLength(65000)),
},
}
const v$ = useVuelidate(
rules,
computed(() => formData.value.items[props.index]),
{ $scope: props.itemValidationScope },
)
function updateTax(data: { index: number; item: DocumentTax }): void {
props.store.$patch((state: Record<string, unknown>) => {
const form = state[props.storeProp] as DocumentFormData
form.items[props.index].taxes![data.index] = data.item
})
const itemTaxes = props.itemData.taxes ?? []
const lastTax = itemTaxes[itemTaxes.length - 1]
if (lastTax?.tax_type_id !== 0) {
props.store.$patch((state: Record<string, unknown>) => {
const form = state[props.storeProp] as DocumentFormData
form.items[props.index].taxes!.push({
id: crypto.randomUUID(),
tax_type_id: 0,
name: '',
amount: 0,
percent: null,
calculation_type: null,
fixed_amount: 0,
compound_tax: false,
})
})
}
syncItemToStore()
}
function setDiscount(): void {
const newValue = formData.value.items[props.index].discount
const absoluteSubtotal = Math.abs(subtotal.value)
if (props.itemData.discount_type === 'percentage') {
updateItemAttribute('discount_val', Math.round((absoluteSubtotal * newValue) / 100))
} else {
updateItemAttribute(
'discount_val',
Math.min(Math.round(newValue * 100), absoluteSubtotal),
)
}
}
function searchVal(val: string): void {
updateItemAttribute('name', val)
}
function onSelectItem(itm: Record<string, unknown>): void {
props.store.$patch((state: Record<string, unknown>) => {
const form = state[props.storeProp] as DocumentFormData
const item = form.items[props.index]
item.name = itm.name as string
item.price = itm.price as number
item.item_id = itm.id as number
item.description = (itm.description as string | null) ?? null
if (itm.unit) {
item.unit_name = (itm.unit as Record<string, string>).name
}
if (form.tax_per_item === 'YES' && itm.taxes) {
let idx = 0
;(itm.taxes as DocumentTax[]).forEach((tax) => {
updateTax({ index: idx, item: { ...tax } })
idx++
})
}
if (form.exchange_rate) {
item.price = Math.round(item.price / form.exchange_rate)
}
})
syncItemToStore()
}
function selectFixed(): void {
if (props.itemData.discount_type === 'fixed') return
updateItemAttribute('discount_val', Math.round(props.itemData.discount * 100))
updateItemAttribute('discount_type', 'fixed')
}
function selectPercentage(): void {
if (props.itemData.discount_type === 'percentage') return
updateItemAttribute('discount_val', (subtotal.value * props.itemData.discount) / 100)
updateItemAttribute('discount_type', 'percentage')
}
function syncItemToStore(): void {
const itemTaxes = formData.value.items?.[props.index]?.taxes ?? []
const data = {
...formData.value.items[props.index],
index: props.index,
total: total.value,
sub_total: subtotal.value,
totalSimpleTax: totalSimpleTax.value,
totalTax: totalTax.value,
tax: totalTax.value,
taxes: [...itemTaxes],
tax_type_ids: itemTaxes.flatMap((tax) =>
tax.tax_type_id ? [tax.tax_type_id] : [],
),
}
props.store.updateItem(data)
}
function updateItemAttribute(attribute: string, value: unknown): void {
props.store.$patch((state: Record<string, unknown>) => {
const form = state[props.storeProp] as DocumentFormData
;(form.items[props.index] as Record<string, unknown>)[attribute] = value
})
syncItemToStore()
}
</script>

View File

@@ -0,0 +1,263 @@
<template>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center text-base" style="flex: 4">
<label class="pr-2 mb-0" align="right">
{{ $t('invoices.item.tax') }}
</label>
<BaseMultiselect
v-model="selectedTax"
value-prop="id"
:options="filteredTypes"
:placeholder="$t('general.select_a_tax')"
open-direction="top"
track-by="name"
searchable
object
label="name"
@update:modelValue="onSelectTax"
>
<template #singlelabel="{ value }">
<div class="absolute left-3.5">
{{ value.name }} -
<template v-if="value.calculation_type === 'fixed'">
<BaseFormatMoney :amount="value.fixed_amount" :currency="currency" />
</template>
<template v-else>
{{ value.percent }} %
</template>
</div>
</template>
<template #option="{ option }">
{{ option.name }} -
<template v-if="option.calculation_type === 'fixed'">
<BaseFormatMoney :amount="option.fixed_amount" :currency="currency" />
</template>
<template v-else>
{{ option.percent }} %
</template>
</template>
<template v-if="canAddTax" #action>
<button
type="button"
class="flex items-center justify-center w-full px-2 py-2 bg-surface-muted border-none outline-hidden cursor-pointer"
@click="openTaxModal"
>
<BaseIcon name="CheckCircleIcon" class="h-5 text-primary-400" />
<label class="ml-2 text-sm leading-none cursor-pointer text-primary-400">
{{ $t('invoices.add_new_tax') }}
</label>
</button>
</template>
</BaseMultiselect>
<br />
</div>
<div class="text-sm text-right" style="flex: 3">
<BaseFormatMoney :amount="taxAmount" :currency="currency" />
</div>
<div class="flex items-center justify-center w-6 h-10 mx-2 cursor-pointer">
<BaseIcon
v-if="taxes.length && index !== taxes.length - 1"
name="TrashIcon"
class="h-5 text-body cursor-pointer"
@click="removeTax(index)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TaxType } from '../../../types/domain/tax'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentFormData, DocumentTax } from './use-document-calculations'
interface Props {
ability: string
store: Record<string, unknown>
storeProp: string
itemIndex: number
index: number
taxData: DocumentTax
taxes: DocumentTax[]
total: number
totalTax: number
discountedTotal: number
currency: Currency | Record<string, unknown>
updateItems: () => void
discount?: number
}
interface Emits {
(e: 'remove', index: number): void
(e: 'update', payload: { index: number; item: DocumentTax }): void
}
const props = withDefaults(defineProps<Props>(), {
ability: '',
discount: 0,
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
// We assume these stores are available globally or injected
// In the v2 arch, we'll use a lighter approach
const taxTypes = computed<TaxType[]>(() => {
// Access taxTypeStore through the store's taxTypes or a global store
return (window as Record<string, unknown>).__taxTypes as TaxType[] ?? []
})
const canAddTax = computed(() => {
return (window as Record<string, unknown>).__userHasAbility?.(props.ability) ?? false
})
const selectedTax = ref<TaxType | null>(null)
const localTax = reactive<DocumentTax>({ ...props.taxData })
const storeData = computed(() => props.store[props.storeProp] as DocumentFormData)
const filteredTypes = computed<(TaxType & { disabled?: boolean })[]>(() => {
const clonedTypes = taxTypes.value.map((a) => ({ ...a, disabled: false }))
return clonedTypes.map((taxType) => {
const found = props.taxes.find((tax) => tax.tax_type_id === taxType.id)
taxType.disabled = !!found
return taxType
})
})
const taxAmount = computed<number>(() => {
if (localTax.calculation_type === 'fixed') {
return localTax.fixed_amount
}
if (props.discountedTotal) {
const taxPerItemEnabled = storeData.value.tax_per_item === 'YES'
const discountPerItemEnabled = storeData.value.discount_per_item === 'YES'
if (taxPerItemEnabled && !discountPerItemEnabled) {
return getTaxAmount()
}
if (storeData.value.tax_included) {
return Math.round(
props.discountedTotal -
props.discountedTotal / (1 + (localTax.percent ?? 0) / 100),
)
}
return Math.round((props.discountedTotal * (localTax.percent ?? 0)) / 100)
}
return 0
})
watch(
() => props.discountedTotal,
() => updateRowTax(),
)
watch(
() => props.totalTax,
() => updateRowTax(),
)
watch(
() => taxAmount.value,
() => updateRowTax(),
)
// Initialize selected tax if editing
if (props.taxData.tax_type_id > 0) {
selectedTax.value =
taxTypes.value.find((_type) => _type.id === props.taxData.tax_type_id) ?? null
}
updateRowTax()
function onSelectTax(val: TaxType): void {
localTax.calculation_type = val.calculation_type
localTax.percent = val.calculation_type === 'percentage' ? val.percent : null
localTax.fixed_amount =
val.calculation_type === 'fixed' ? val.fixed_amount : 0
localTax.tax_type_id = val.id
localTax.name = val.name
updateRowTax()
}
function updateRowTax(): void {
if (localTax.tax_type_id === 0) {
return
}
emit('update', {
index: props.index,
item: {
...localTax,
amount: taxAmount.value,
},
})
}
function openTaxModal(): void {
// Modal integration - emit event or use modal store
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('settings.tax_types.add_tax'),
componentName: 'TaxTypeModal',
data: { itemIndex: props.itemIndex, taxIndex: props.index },
size: 'sm',
})
}
function removeTax(index: number): void {
const store = props.store as Record<string, Record<string, unknown>>
const formData = store[props.storeProp] as DocumentFormData
formData.items[props.itemIndex].taxes?.splice(index, 1)
const item = formData.items[props.itemIndex]
item.tax = 0
item.totalTax = 0
}
function getTaxAmount(): number {
if (localTax.calculation_type === 'fixed') {
return localTax.fixed_amount
}
let itemsTotal = 0
let discount = 0
const itemTotal = props.discountedTotal
const modelDiscount = storeData.value.discount ?? 0
const type = storeData.value.discount_type
let discountedTotal = props.discountedTotal
if (modelDiscount > 0) {
storeData.value.items.forEach((item) => {
itemsTotal += item.total ?? 0
})
const proportion = parseFloat((itemTotal / itemsTotal).toFixed(2))
discount =
type === 'fixed'
? modelDiscount * 100
: (itemsTotal * modelDiscount) / 100
const itemDiscount = Math.round(discount * proportion)
discountedTotal = itemTotal - itemDiscount
}
if (storeData.value.tax_included) {
return Math.round(
discountedTotal -
discountedTotal / (1 + (localTax.percent ?? 0) / 100),
)
}
return Math.round((discountedTotal * (localTax.percent ?? 0)) / 100)
}
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="rounded-xl border border-line-light shadow overflow-hidden bg-surface">
<!-- Tax Included Toggle -->
<div
v-if="taxIncludedSetting === 'YES'"
class="flex items-center justify-end w-full px-6 text-base border-b border-line-light cursor-pointer text-primary-400 bg-surface"
>
<BaseSwitchSection
v-model="taxIncludedField"
:title="$t('settings.tax_types.tax_included')"
:store="store"
:store-prop="storeProp"
/>
</div>
<table class="text-center item-table min-w-full">
<colgroup>
<col style="width: 40%; min-width: 280px" />
<col style="width: 10%; min-width: 120px" />
<col style="width: 15%; min-width: 120px" />
<col
v-if="formData.discount_per_item === 'YES'"
style="width: 15%; min-width: 160px"
/>
<col style="width: 15%; min-width: 120px" />
</colgroup>
<thead class="bg-surface-secondary border-b border-line-light">
<tr>
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body">
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<span v-else class="pl-7">
{{ $t('items.item', 2) }}
</span>
</th>
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-right text-body">
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<span v-else>
{{ $t('invoices.item.quantity') }}
</span>
</th>
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body">
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<span v-else>
{{ $t('invoices.item.price') }}
</span>
</th>
<th
v-if="formData.discount_per_item === 'YES'"
class="px-5 py-3 text-sm not-italic font-medium leading-5 text-left text-body"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<span v-else>
{{ $t('invoices.item.discount') }}
</span>
</th>
<th class="px-5 py-3 text-sm not-italic font-medium leading-5 text-right text-body">
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<span v-else class="pr-10 column-heading">
{{ $t('invoices.item.amount') }}
</span>
</th>
</tr>
</thead>
<draggable
v-model="formData.items"
item-key="id"
tag="tbody"
handle=".handle"
>
<template #item="{ element, index }">
<DocumentItemRow
:key="element.id"
:index="index"
:item-data="element"
:loading="isLoading"
:currency="defaultCurrency"
:item-validation-scope="itemValidationScope"
:invoice-items="formData.items"
:store="store"
:store-prop="storeProp"
/>
</template>
</draggable>
</table>
<div
class="flex items-center justify-center w-full px-6 py-3 text-base border-t border-line-light cursor-pointer text-primary-400 hover:bg-primary-100"
@click="store.addItem()"
>
<BaseIcon name="PlusCircleIcon" class="mr-2" />
{{ $t('general.add_new_item') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import draggable from 'vuedraggable'
import DocumentItemRow from './DocumentItemRow.vue'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentFormData } from './use-document-calculations'
interface Props {
store: Record<string, unknown> & {
addItem: () => void
}
storeProp: string
currency: Currency | Record<string, unknown> | string | null
isLoading?: boolean
itemValidationScope?: string
taxIncludedSetting?: string
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
itemValidationScope: '',
taxIncludedSetting: 'NO',
})
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const defaultCurrency = computed(() => {
if (props.currency) {
return props.currency
}
return null
})
const taxIncludedField = computed<boolean>({
get: () => {
return !!formData.value.tax_included
},
set: (value: boolean) => {
formData.value.tax_included = value
},
})
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="mb-6">
<div class="z-20 text-sm font-semibold leading-5 text-primary-400 float-right">
<NoteSelectPopup :type="type" @select="onSelectNote" />
</div>
<label class="text-heading font-medium mb-4 text-sm">
{{ $t('invoices.notes') }}
</label>
<BaseCustomInput
v-model="notes"
:content-loading="contentLoading"
:fields="fields"
class="mt-1"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NoteSelectPopup from './NoteSelectPopup.vue'
import type { DocumentFormData } from './use-document-calculations'
import type { NoteType } from '../../../types/domain/note'
interface Props {
store: Record<string, unknown>
storeProp: string
fields: Record<string, unknown> | null
type: NoteType | string | null
contentLoading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fields: null,
type: null,
contentLoading: false,
})
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const notes = computed<string | null>({
get: () => formData.value.notes,
set: (value: string | null) => {
formData.value.notes = value
},
})
function onSelectNote(data: { notes: string }): void {
formData.value.notes = String(data.notes)
}
</script>

View File

@@ -0,0 +1,388 @@
<template>
<div
class="px-5 py-4 mt-6 bg-surface border border-line-light border-solid rounded-xl shadow md:min-w-[390px] min-w-[300px] lg:mt-7"
>
<!-- Subtotal -->
<div class="flex items-center justify-between w-full">
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-subtle uppercase">
{{ $t('estimates.sub_total') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="store.getSubTotal" :currency="defaultCurrency" />
</label>
</div>
<!-- Net Total for per-item tax mode -->
<div v-if="formData.tax_per_item === 'YES'">
<div
v-if="formData.tax_included"
class="flex items-center justify-between w-full"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-muted uppercase">
{{ $t('estimates.net_total') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="store.getNetTotal" :currency="currency" />
</label>
</div>
</div>
<!-- Item-wise tax breakdown -->
<div
v-for="tax in itemWiseTaxes"
:key="tax.tax_type_id"
class="flex items-center justify-between w-full"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else-if="formData.tax_per_item === 'YES'"
class="m-0 text-sm font-semibold leading-5 text-muted uppercase"
>
<template v-if="tax.calculation_type === 'percentage'">
{{ tax.name }} - {{ tax.percent }}%
</template>
<template v-else>
{{ tax.name }} -
<BaseFormatMoney :amount="tax.fixed_amount" :currency="defaultCurrency" />
</template>
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else-if="formData.tax_per_item === 'YES'"
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="tax.amount" :currency="defaultCurrency" />
</label>
</div>
<!-- Global Discount -->
<div
v-if="formData.discount_per_item === 'NO' || formData.discount_per_item === null"
class="flex items-center justify-between w-full mt-2"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-subtle uppercase">
{{ $t('estimates.discount') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText
:lines="1"
class="w-24 h-8 border border-line-light rounded-md"
/>
</BaseContentPlaceholders>
<div v-else class="flex" style="width: 140px" role="group">
<BaseInput
v-model="totalDiscount"
class="border-r-0 focus:border-r-2 rounded-tr-sm rounded-br-sm h-[38px]"
/>
<BaseDropdown position="bottom-end">
<template #activator>
<BaseButton
class="p-2 rounded-none rounded-tr-md rounded-br-md"
type="button"
variant="white"
>
<span class="flex items-center">
{{ formData.discount_type === 'fixed' ? defaultCurrencySymbol : '%' }}
<BaseIcon name="ChevronDownIcon" class="w-4 h-4 ml-1 text-muted" />
</span>
</BaseButton>
</template>
<BaseDropdownItem @click="selectFixed">
{{ $t('general.fixed') }}
</BaseDropdownItem>
<BaseDropdownItem @click="selectPercentage">
{{ $t('general.percentage') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
</div>
<!-- Net Total for global tax mode -->
<div
v-if="formData.tax_per_item === 'NO' || formData.tax_per_item === null"
class="flex items-center justify-between w-full mt-2"
>
<div
v-if="formData.tax_included"
class="flex items-center justify-between w-full"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="text-sm font-semibold leading-5 text-muted uppercase">
{{ $t('estimates.net_total') }}
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center m-0 text-lg text-heading uppercase"
>
<BaseFormatMoney :amount="store.getNetTotal" :currency="currency" />
</label>
</div>
</div>
<!-- Global taxes list -->
<div
v-if="formData.tax_per_item === 'NO' || formData.tax_per_item === null"
>
<div
v-for="(tax, index) in taxes"
:key="tax.id"
class="flex items-center justify-between w-full mt-2 text-sm"
>
<label v-if="tax.calculation_type === 'percentage'" class="font-semibold leading-5 text-muted uppercase">
{{ tax.name }} ({{ tax.percent }} %)
</label>
<label v-else class="font-semibold leading-5 text-muted uppercase">
{{ tax.name }} (<BaseFormatMoney :amount="tax.fixed_amount" :currency="currency" />)
</label>
<label class="flex items-center justify-center text-lg text-heading">
<BaseFormatMoney :amount="tax.amount" :currency="currency" />
<BaseIcon
name="TrashIcon"
class="h-5 ml-2 cursor-pointer"
@click="removeTax(tax.id)"
/>
</label>
</div>
</div>
<!-- Add tax popup -->
<div
v-if="formData.tax_per_item === 'NO' || formData.tax_per_item === null"
ref="taxModal"
class="float-right pt-2 pb-4"
>
<TaxSelectPopup
:store-prop="storeProp"
:store="store"
:type="taxPopupType"
@select:tax-type="onSelectTax"
/>
</div>
<!-- Total Amount -->
<div
class="flex items-center justify-between w-full pt-2 mt-5 border-t border-line-light border-solid"
>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label v-else class="m-0 text-sm font-semibold leading-5 text-subtle uppercase">
{{ $t('estimates.total') }} {{ $t('estimates.amount') }}:
</label>
<BaseContentPlaceholders v-if="isLoading">
<BaseContentPlaceholdersText :lines="1" class="w-16 h-5" />
</BaseContentPlaceholders>
<label
v-else
class="flex items-center justify-center text-lg uppercase text-primary-400"
>
<BaseFormatMoney :amount="store.getTotal" :currency="defaultCurrency" />
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import TaxSelectPopup from './TaxSelectPopup.vue'
import type { Currency } from '../../../types/domain/currency'
import type { TaxType } from '../../../types/domain/tax'
import type { DocumentFormData, DocumentTax, DocumentStore, DocumentItem } from './use-document-calculations'
interface Props {
store: DocumentStore & {
$patch: (fn: (state: Record<string, unknown>) => void) => void
[key: string]: unknown
}
storeProp: string
taxPopupType?: string
currency?: Currency | Record<string, unknown> | string
isLoading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
taxPopupType: '',
currency: '',
isLoading: false,
})
const taxModal = ref<HTMLElement | null>(null)
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const defaultCurrency = computed(() => {
if (props.currency) {
return props.currency
}
return null
})
const defaultCurrencySymbol = computed<string>(() => {
const curr = defaultCurrency.value as Record<string, unknown> | null
return (curr?.symbol as string) ?? '$'
})
watch(
() => formData.value.items,
() => setDiscount(),
{ deep: true },
)
const totalDiscount = computed<number>({
get: () => formData.value.discount,
set: (newValue: number) => {
formData.value.discount = newValue
setDiscount()
},
})
const taxes = computed<DocumentTax[]>({
get: () => formData.value.taxes,
set: (value: DocumentTax[]) => {
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes = value
})
},
})
interface AggregatedTax {
tax_type_id: number
amount: number
percent: number | null
name: string
calculation_type: string | null
fixed_amount: number
}
const itemWiseTaxes = computed<AggregatedTax[]>(() => {
const result: AggregatedTax[] = []
formData.value.items.forEach((item: DocumentItem) => {
if (item.taxes) {
item.taxes.forEach((tax: Partial<DocumentTax>) => {
const found = result.find((_tax) => _tax.tax_type_id === tax.tax_type_id)
if (found) {
found.amount += tax.amount ?? 0
} else if (tax.tax_type_id) {
result.push({
tax_type_id: tax.tax_type_id,
amount: Math.round(tax.amount ?? 0),
percent: tax.percent ?? null,
name: tax.name ?? '',
calculation_type: tax.calculation_type ?? null,
fixed_amount: tax.fixed_amount ?? 0,
})
}
})
}
})
return result
})
function setDiscount(): void {
const newValue = formData.value.discount
if (formData.value.discount_type === 'percentage') {
formData.value.discount_val = Math.round((props.store.getSubTotal * newValue) / 100)
return
}
formData.value.discount_val = Math.round(newValue * 100)
}
function selectFixed(): void {
if (formData.value.discount_type === 'fixed') return
formData.value.discount_val = Math.round(formData.value.discount * 100)
formData.value.discount_type = 'fixed'
}
function selectPercentage(): void {
if (formData.value.discount_type === 'percentage') return
const val = Math.round(formData.value.discount * 100) / 100
formData.value.discount_val = Math.round((props.store.getSubTotal * val) / 100)
formData.value.discount_type = 'percentage'
}
function onSelectTax(selectedTax: TaxType): void {
let amount = 0
if (
selectedTax.calculation_type === 'percentage' &&
props.store.getSubtotalWithDiscount &&
selectedTax.percent
) {
amount = Math.round(
(props.store.getSubtotalWithDiscount * selectedTax.percent) / 100,
)
} else if (selectedTax.calculation_type === 'fixed') {
amount = selectedTax.fixed_amount
}
const data: DocumentTax = {
id: crypto.randomUUID(),
name: selectedTax.name,
percent: selectedTax.percent,
tax_type_id: selectedTax.id,
amount,
calculation_type: selectedTax.calculation_type,
fixed_amount: selectedTax.fixed_amount,
compound_tax: selectedTax.compound_tax ?? false,
}
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes.push({ ...data })
})
}
function updateTax(data: DocumentTax): void {
const tax = formData.value.taxes.find((t: DocumentTax) => t.id === data.id)
if (tax) {
Object.assign(tax, { ...data })
}
}
function removeTax(id: number | string): void {
const index = formData.value.taxes.findIndex((tax: DocumentTax) => tax.id === id)
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes.splice(index, 1)
})
}
</script>

View File

@@ -0,0 +1,189 @@
<template>
<BaseInputGroup
v-if="showExchangeRate && selectedCurrency"
:content-loading="isFetching && !isEdit"
:label="$t('settings.exchange_rate.exchange_rate')"
:error="
v.exchange_rate.$error && v.exchange_rate.$errors[0].$message
"
required
>
<template #labelRight>
<div v-if="hasActiveProvider && isEdit">
<BaseIcon
v-tooltip="{ content: 'Fetch Latest Exchange rate' }"
name="ArrowPathIcon"
:class="`h-4 w-4 text-primary-500 cursor-pointer outline-hidden ${
isFetching
? ' animate-spin rotate-180 cursor-not-allowed pointer-events-none '
: ''
}`"
@click="getCurrentExchangeRate(customerCurrency)"
/>
</div>
</template>
<BaseInput
v-model="exchangeRate"
:content-loading="isFetching && !isEdit"
:addon="`1 ${selectedCurrency.code} =`"
:disabled="isFetching"
@input="v.exchange_rate.$touch()"
>
<template #right>
<span class="text-muted sm:text-sm">
{{ companyCurrency?.code ?? '' }}
</span>
</template>
</BaseInput>
<span class="text-subtle text-xs mt-2 font-light">
{{
$t('settings.exchange_rate.exchange_help_text', {
currency: selectedCurrency.code,
baseCurrency: companyCurrency?.code ?? '',
})
}}
</span>
</BaseInputGroup>
</template>
<script setup lang="ts">
import { watch, computed, ref, onBeforeUnmount } from 'vue'
import { exchangeRateService } from '../../../api/services/exchange-rate.service'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentFormData } from './use-document-calculations'
interface Props {
v: Record<string, { $error: boolean; $errors: Array<{ $message: string }>; $touch: () => void }>
isLoading?: boolean
store: Record<string, unknown> & {
showExchangeRate: boolean
}
storeProp: string
isEdit?: boolean
customerCurrency?: number | string | null
companyCurrency?: Currency | null
currencies?: Currency[]
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
isEdit: false,
customerCurrency: null,
companyCurrency: null,
currencies: () => [],
})
const hasActiveProvider = ref<boolean>(false)
const isFetching = ref<boolean>(false)
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const showExchangeRate = computed<boolean>(() => {
return props.store.showExchangeRate
})
const exchangeRate = computed<number | null | string>({
get: () => formData.value.exchange_rate ?? null,
set: (value) => {
formData.value.exchange_rate = value as number
},
})
const selectedCurrency = computed<Currency | null>(() => {
return (
props.currencies.find((c) => c.id === formData.value.currency_id) ?? null
)
})
const isCurrencyDifferent = computed<boolean>(() => {
return props.companyCurrency?.id !== props.customerCurrency
})
watch(
() => (formData.value as Record<string, unknown>).customer,
(v) => {
setCustomerCurrency(v as Record<string, unknown> | null)
},
{ deep: true },
)
watch(
() => formData.value.currency_id,
(v) => {
onChangeCurrency(v)
},
{ immediate: true },
)
watch(
() => props.customerCurrency,
(v) => {
if (v && props.isEdit) {
checkForActiveProvider()
}
},
{ immediate: true },
)
function checkForActiveProvider(): void {
if (isCurrencyDifferent.value && props.customerCurrency) {
exchangeRateService
.getActiveProvider(Number(props.customerCurrency))
.then((res) => {
if (res.has_active_provider) {
hasActiveProvider.value = true
}
})
.catch(() => {
// Silently fail
})
}
}
function setCustomerCurrency(v: Record<string, unknown> | null): void {
if (v) {
const currency = v.currency as Currency | undefined
if (currency) {
formData.value.currency_id = currency.id
}
} else if (props.companyCurrency) {
formData.value.currency_id = props.companyCurrency.id
}
}
async function onChangeCurrency(v: number | undefined): Promise<void> {
if (v !== props.companyCurrency?.id) {
if (!props.isEdit && v) {
await getCurrentExchangeRate(v)
}
props.store.showExchangeRate = true
} else {
props.store.showExchangeRate = false
}
}
async function getCurrentExchangeRate(v: number | string | null | undefined): Promise<void> {
if (!v) return
isFetching.value = true
try {
const res = await exchangeRateService.getRate(Number(v))
if (res && res.exchange_rate != null) {
formData.value.exchange_rate = res.exchange_rate
} else {
formData.value.exchange_rate = null
}
} catch {
// Silently fail
} finally {
isFetching.value = false
}
}
onBeforeUnmount(() => {
props.store.showExchangeRate = false
})
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="w-full">
<Popover v-slot="{ open: isOpen }">
<PopoverButton
v-if="canViewNotes"
class="flex items-center z-10 font-medium text-primary-400 focus:outline-hidden focus:border-none"
@click="fetchInitialData"
>
<BaseIcon name="PlusIcon" class="w-4 h-4 font-medium text-primary-400" />
{{ $t('general.insert_note') }}
</PopoverButton>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<PopoverPanel
v-slot="{ close }"
class="absolute z-20 px-4 mt-3 sm:px-0 w-screen max-w-full left-0 top-3"
>
<div class="overflow-hidden rounded-md shadow-lg ring-1 ring-black/5">
<div class="relative grid bg-surface">
<div class="relative p-4">
<BaseInput
v-model="textSearch"
:placeholder="$t('general.search')"
type="text"
class="text-heading"
/>
</div>
<div
v-if="filteredNotes.length > 0"
class="relative flex flex-col overflow-auto list max-h-36"
>
<div
v-for="(note, idx) in filteredNotes"
:key="idx"
tabindex="2"
class="px-6 py-4 border-b border-line-default border-solid cursor-pointer hover:bg-surface-tertiary hover:cursor-pointer last:border-b-0"
@click="selectNote(idx, close)"
>
<div class="flex justify-between px-2">
<label
class="m-0 text-base font-semibold leading-tight text-body cursor-pointer"
>
{{ note.name }}
</label>
</div>
</div>
</div>
<div v-else class="flex justify-center p-5 text-subtle">
<label class="text-base text-muted">
{{ $t('general.no_note_found') }}
</label>
</div>
</div>
<button
v-if="canManageNotes"
type="button"
class="h-10 flex items-center justify-center w-full px-2 py-3 bg-surface-muted border-none outline-hidden"
@click="openNoteModal"
>
<BaseIcon name="CheckCircleIcon" class="text-primary-400" />
<label
class="m-0 ml-3 text-sm leading-none cursor-pointer font-base text-primary-400"
>
{{ $t('settings.customization.notes.add_new_note') }}
</label>
</button>
</div>
</PopoverPanel>
</transition>
</Popover>
</div>
</template>
<script setup lang="ts">
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Note } from '../../../types/domain/note'
import { noteService } from '../../../api/services/note.service'
interface Props {
type?: string | null
canViewNotes?: boolean
canManageNotes?: boolean
}
interface Emits {
(e: 'select', data: Note): void
}
const props = withDefaults(defineProps<Props>(), {
type: null,
canViewNotes: true,
canManageNotes: false,
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const textSearch = ref<string | null>(null)
const notes = ref<Note[]>([])
const filteredNotes = computed<Note[]>(() => {
if (textSearch.value) {
return notes.value.filter((el) =>
el.name.toLowerCase().includes(textSearch.value!.toLowerCase()),
)
}
return notes.value
})
async function fetchInitialData(): Promise<void> {
try {
const response = await noteService.list({
search: '',
orderByField: '',
orderBy: 'asc',
})
notes.value = (response as unknown as { data: Note[] }).data ?? []
} catch {
// Silently fail
}
}
function selectNote(index: number, close: () => void): void {
emit('select', { ...notes.value[index] })
textSearch.value = null
close()
}
function openNoteModal(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('settings.customization.notes.add_note'),
componentName: 'NoteModal',
size: 'lg',
data: props.type,
})
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="w-full mt-4 tax-select">
<Popover v-slot="{ open: isOpen }" class="relative">
<PopoverButton
class="flex items-center text-sm font-medium text-primary-400 focus:outline-hidden focus:border-none"
>
<BaseIcon name="PlusIcon" class="w-4 h-4 font-medium text-primary-400" />
{{ $t('settings.tax_types.add_tax') }}
</PopoverButton>
<div class="relative w-full max-w-md px-4">
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<PopoverPanel
v-slot="{ close }"
style="min-width: 350px; margin-left: 62px; top: -28px"
class="absolute z-10 px-4 py-2 -translate-x-full sm:px-0"
>
<div class="overflow-hidden rounded-xl shadow ring-1 ring-black/5">
<!-- Search Input -->
<div class="relative bg-surface">
<div class="relative p-4">
<BaseInput
v-model="textSearch"
:placeholder="$t('general.search')"
type="text"
class="text-heading"
/>
</div>
<!-- List of Taxes -->
<div
v-if="filteredTaxType.length > 0"
class="relative flex flex-col overflow-auto list max-h-36 border-t border-line-light"
>
<div
v-for="(taxType, idx) in filteredTaxType"
:key="idx"
:class="{
'bg-surface-tertiary cursor-not-allowed opacity-50 pointer-events-none':
existingTaxIds.has(taxType.id),
}"
tabindex="2"
class="px-6 py-4 border-b border-line-light border-solid cursor-pointer hover:bg-surface-tertiary hover:cursor-pointer last:border-b-0"
@click="selectTaxType(taxType, close)"
>
<div class="flex justify-between px-2">
<label
class="m-0 text-base font-semibold leading-tight text-body cursor-pointer"
>
{{ taxType.name }}
</label>
<label
class="m-0 text-base font-semibold text-body cursor-pointer"
>
<template v-if="taxType.calculation_type === 'fixed'">
<BaseFormatMoney :amount="taxType.fixed_amount" :currency="companyCurrency" />
</template>
<template v-else>
{{ taxType.percent }} %
</template>
</label>
</div>
</div>
</div>
<div v-else class="flex justify-center p-5 text-subtle">
<label class="text-base text-muted cursor-pointer">
{{ $t('general.no_tax_found') }}
</label>
</div>
</div>
<!-- Add new Tax action -->
<button
v-if="canCreateTaxType"
type="button"
class="flex items-center justify-center w-full h-10 px-2 py-3 bg-surface-muted border-none outline-hidden"
@click="openTaxTypeModal"
>
<BaseIcon name="CheckCircleIcon" class="text-primary-400" />
<label
class="m-0 ml-3 text-sm leading-none cursor-pointer font-base text-primary-400"
>
{{ $t('estimates.add_new_tax') }}
</label>
</button>
</div>
</PopoverPanel>
</transition>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TaxType } from '../../../types/domain/tax'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentFormData, DocumentTax } from './use-document-calculations'
interface Props {
type?: string | null
store: Record<string, unknown>
storeProp: string
taxTypes?: TaxType[]
companyCurrency?: Currency | Record<string, unknown> | null
canCreateTaxType?: boolean
}
interface Emits {
(e: 'select:taxType', taxType: TaxType): void
}
const props = withDefaults(defineProps<Props>(), {
type: null,
taxTypes: () => [],
companyCurrency: null,
canCreateTaxType: false,
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const textSearch = ref<string | null>(null)
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const filteredTaxType = computed<TaxType[]>(() => {
if (textSearch.value) {
return props.taxTypes.filter((el) =>
el.name.toLowerCase().includes(textSearch.value!.toLowerCase()),
)
}
return props.taxTypes
})
const taxes = computed<DocumentTax[]>(() => {
return formData.value.taxes
})
const existingTaxIds = computed<Set<number>>(() => {
return new Set(taxes.value.map((t) => t.tax_type_id))
})
function selectTaxType(data: TaxType, close: () => void): void {
emit('select:taxType', { ...data })
close()
}
function openTaxTypeModal(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('settings.tax_types.add_tax'),
componentName: 'TaxTypeModal',
size: 'sm',
refreshData: (data: TaxType) => emit('select:taxType', data),
})
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div>
<label class="flex text-heading font-medium text-sm mb-2">
{{ $t('general.select_template') }}
<span class="text-sm text-red-500"> *</span>
</label>
<BaseButton
type="button"
class="flex justify-center w-full text-sm lg:w-auto hover:bg-surface-muted"
variant="gray"
@click="openTemplateModal"
>
<template #right="slotProps">
<BaseIcon name="PencilIcon" :class="slotProps.class" />
</template>
{{ templateName }}
</BaseButton>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DocumentFormData } from './use-document-calculations'
interface Props {
store: Record<string, unknown> & {
templates: Array<{ name: string; path?: string }>
}
storeProp: string
isMarkAsDefault?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isMarkAsDefault: false,
})
const { t } = useI18n()
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
const templateName = computed<string>(() => {
return formData.value.template_name ?? ''
})
function openTemplateModal(): void {
let markAsDefaultDescription = ''
if (props.storeProp === 'newEstimate') {
markAsDefaultDescription = t('estimates.mark_as_default_estimate_template_description')
} else if (props.storeProp === 'newInvoice') {
markAsDefaultDescription = t('invoices.mark_as_default_invoice_template_description')
}
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('general.choose_template'),
componentName: 'SelectTemplate',
data: {
templates: props.store.templates,
store: props.store,
storeProp: props.storeProp,
isMarkAsDefault: props.isMarkAsDefault,
markAsDefaultDescription,
},
})
}
</script>

View File

@@ -0,0 +1,25 @@
export { default as DocumentItemsTable } from './DocumentItemsTable.vue'
export { default as DocumentItemRow } from './DocumentItemRow.vue'
export { default as DocumentItemRowTax } from './DocumentItemRowTax.vue'
export { default as DocumentTotals } from './DocumentTotals.vue'
export { default as DocumentNotes } from './DocumentNotes.vue'
export { default as TaxSelectPopup } from './TaxSelectPopup.vue'
export { default as NoteSelectPopup } from './NoteSelectPopup.vue'
export { default as TemplateSelectButton } from './TemplateSelectButton.vue'
export { default as ExchangeRateConverter } from './ExchangeRateConverter.vue'
export {
useDocumentCalculations,
calcItemSubtotal,
calcItemDiscountVal,
calcItemTotal,
calcTaxAmount,
} from './use-document-calculations'
export type {
DocumentItem,
DocumentFormData,
DocumentTax,
DocumentStore,
UseDocumentCalculationsOptions,
} from './use-document-calculations'

View File

@@ -0,0 +1,180 @@
import { computed, type Ref } from 'vue'
import type { Tax } from '../../../types/domain/tax'
export interface DocumentItem {
id: number | string
name: string
description: string | null
quantity: number
price: number
discount: number
discount_val: number
discount_type: 'fixed' | 'percentage'
tax: number
total: number
sub_total?: number
totalTax?: number
totalSimpleTax?: number
totalCompoundTax?: number
taxes?: Partial<Tax>[]
item_id?: number | null
unit_name?: string | null
invoice_id?: number | null
estimate_id?: number | null
[key: string]: unknown
}
export interface DocumentFormData {
id: number | null
customer: Record<string, unknown> | null
customer_id: number | null
items: DocumentItem[]
taxes: DocumentTax[]
discount: number
discount_val: number
discount_type: 'fixed' | 'percentage'
tax_per_item: string | null
tax_included: boolean | null
discount_per_item: string | null
notes: string | null
template_name: string | null
exchange_rate?: number | null
currency_id?: number
selectedCurrency?: Record<string, unknown> | string
[key: string]: unknown
}
export interface DocumentTax {
id: number | string
tax_type_id: number
name: string
amount: number
percent: number | null
calculation_type: string | null
fixed_amount: number
compound_tax: boolean
type?: string
[key: string]: unknown
}
export interface DocumentStore {
getSubTotal: number
getNetTotal: number
getTotalSimpleTax: number
getTotalCompoundTax: number
getTotalTax: number
getSubtotalWithDiscount: number
getTotal: number
[key: string]: unknown
}
export interface UseDocumentCalculationsOptions {
items: Ref<DocumentItem[]>
taxes: Ref<DocumentTax[]>
discountVal: Ref<number>
taxPerItem: Ref<string | null>
taxIncluded: Ref<boolean | null>
}
export function useDocumentCalculations(options: UseDocumentCalculationsOptions) {
const { items, taxes, discountVal, taxPerItem, taxIncluded } = options
const subTotal = computed<number>(() => {
return items.value.reduce((sum: number, item: DocumentItem) => {
return sum + (item.total ?? 0)
}, 0)
})
const totalSimpleTax = computed<number>(() => {
return taxes.value.reduce((sum: number, tax: DocumentTax) => {
if (!tax.compound_tax) {
return sum + (tax.amount ?? 0)
}
return sum
}, 0)
})
const totalCompoundTax = computed<number>(() => {
return taxes.value.reduce((sum: number, tax: DocumentTax) => {
if (tax.compound_tax) {
return sum + (tax.amount ?? 0)
}
return sum
}, 0)
})
const totalTax = computed<number>(() => {
if (taxPerItem.value === 'NO' || taxPerItem.value === null) {
return totalSimpleTax.value + totalCompoundTax.value
}
return items.value.reduce((sum: number, item: DocumentItem) => {
return sum + (item.tax ?? 0)
}, 0)
})
const subtotalWithDiscount = computed<number>(() => {
return subTotal.value - discountVal.value
})
const netTotal = computed<number>(() => {
return subtotalWithDiscount.value - totalTax.value
})
const total = computed<number>(() => {
if (taxIncluded.value) {
return subtotalWithDiscount.value
}
return subtotalWithDiscount.value + totalTax.value
})
return {
subTotal,
totalSimpleTax,
totalCompoundTax,
totalTax,
subtotalWithDiscount,
netTotal,
total,
}
}
/** Calculate item-level subtotal (price * quantity) */
export function calcItemSubtotal(price: number, quantity: number): number {
return Math.round(price * quantity)
}
/** Calculate item-level discount value */
export function calcItemDiscountVal(
subtotal: number,
discount: number,
discountType: 'fixed' | 'percentage',
): number {
const absSubtotal = Math.abs(subtotal)
if (discountType === 'percentage') {
return Math.round((absSubtotal * discount) / 100)
}
return Math.min(Math.round(discount * 100), absSubtotal)
}
/** Calculate item-level total after discount */
export function calcItemTotal(subtotal: number, discountVal: number): number {
return subtotal - discountVal
}
/** Calculate tax amount for a given total and tax config */
export function calcTaxAmount(
total: number,
percent: number | null,
fixedAmount: number | null,
calculationType: string | null,
taxIncluded: boolean | null,
): number {
if (calculationType === 'fixed' && fixedAmount != null) {
return fixedAmount
}
if (!total || !percent) return 0
if (taxIncluded) {
return Math.round(total - total / (1 + percent / 100))
}
return Math.round((total * percent) / 100)
}

View File

@@ -0,0 +1,134 @@
<template>
<div class="grid h-screen grid-cols-12 overflow-y-hidden bg-surface-tertiary">
<NotificationRoot />
<div
class="
flex items-center justify-center w-full max-w-sm col-span-12
p-4 mx-auto text-heading md:p-8 md:col-span-6 lg:col-span-4
flex-2 md:pb-48 md:pt-40
"
>
<div class="w-full">
<MainLogo
v-if="!loginPageLogo"
class="block w-48 h-auto max-w-full mb-32 text-primary-500"
/>
<img
v-else
:src="loginPageLogo"
class="block w-48 h-auto max-w-full mb-32 text-primary-500"
/>
<router-view />
<div
class="
pt-24 mt-0 text-sm not-italic font-medium leading-relaxed
text-left text-subtle md:pt-40
"
>
<p v-if="copyrightText" class="mb-3">
{{ copyrightText }}
</p>
<p v-else class="mb-3">
Powered by
<a
href="https://invoiceshelf.com/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 hover:underline"
>InvoiceShelf</a>
</p>
</div>
</div>
</div>
<div
class="
relative flex-col items-center justify-center hidden w-full h-full
pl-10 bg-no-repeat bg-cover md:col-span-6 lg:col-span-8
md:flex content-box overflow-hidden
"
>
<LoginBackground class="absolute h-full w-full" />
<LoginPlanetCrater
class="absolute z-10 top-0 right-0 h-[300px] w-[420px]"
/>
<LoginBackgroundOverlay class="absolute h-full w-full right-[7.5%]" />
<div class="md:pl-10 xl:pl-0 relative z-50 w-7/12 xl:w-5/12">
<h1
class="
hidden mb-3 text-3xl leading-normal text-left text-white
xl:text-5xl xl:leading-tight md:none lg:block
"
>
{{ pageHeading }}
</h1>
<p
class="
hidden text-sm not-italic font-normal leading-normal text-left
text-gray-100 xl:text-base xl:leading-6 md:none lg:block
"
>
{{ pageDescription }}
</p>
</div>
<LoginBottomVector
class="
absolute z-50 w-full bg-no-repeat content-bottom
h-[15vw] lg:h-[22vw] right-[32%] bottom-0
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NotificationRoot from '../../components/notifications/NotificationRoot.vue'
import MainLogo from '../../components/icons/MainLogo.vue'
import LoginBackground from '../../components/icons/LoginBackground.vue'
import LoginPlanetCrater from '../../components/icons/LoginPlanetCrater.vue'
import LoginBottomVector from '../../components/icons/LoginBottomVector.vue'
import LoginBackgroundOverlay from '../../components/icons/LoginBackgroundOverlay.vue'
declare global {
interface Window {
login_page_heading?: string
login_page_description?: string
copyright_text?: string
login_page_logo?: string
}
}
const pageHeading = computed<string>(() => {
if (window.login_page_heading) {
return window.login_page_heading
}
return 'Simple Invoicing for Individuals Small Businesses'
})
const pageDescription = computed<string>(() => {
if (window.login_page_description) {
return window.login_page_description
}
return 'InvoiceShelf helps you track expenses, record payments & generate beautiful invoices & estimates.'
})
const copyrightText = computed<string | null>(() => {
return window.copyright_text ?? null
})
const loginPageLogo = computed<string | false>(() => {
if (window.login_page_logo) {
return window.login_page_logo
}
return false
})
</script>

View File

@@ -0,0 +1,99 @@
<template>
<div v-if="isAppLoaded" class="h-full">
<NotificationRoot />
<ImpersonationBanner />
<SiteHeader />
<SiteSidebar v-if="hasCompany" />
<main
:class="[
'h-screen h-screen-ios overflow-y-auto min-h-0 transition-all duration-300',
hasCompany
? globalStore.isSidebarCollapsed
? 'md:pl-16'
: 'md:pl-56 xl:pl-64'
: '',
]"
>
<div class="pt-16 pb-16">
<router-view />
</div>
</main>
</div>
<BaseGlobalLoader v-else />
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGlobalStore } from '../../stores/global.store'
import { useUserStore } from '../../stores/user.store'
import { useModalStore } from '../../stores/modal.store'
import { useCompanyStore } from '../../stores/company.store'
import SiteHeader from './partials/SiteHeader.vue'
import SiteSidebar from './partials/SiteSidebar.vue'
import NotificationRoot from '../../components/notifications/NotificationRoot.vue'
import ImpersonationBanner from './partials/ImpersonationBanner.vue'
interface RouteMeta {
ability?: string | string[]
isSuperAdmin?: boolean
isOwner?: boolean
}
const globalStore = useGlobalStore()
const route = useRoute()
const userStore = useUserStore()
const router = useRouter()
const modalStore = useModalStore()
const { t } = useI18n()
const companyStore = useCompanyStore()
const isAppLoaded = computed<boolean>(() => {
return globalStore.isAppLoaded
})
const hasCompany = computed<boolean>(() => {
return !!companyStore.selectedCompany || companyStore.isAdminMode
})
onMounted(() => {
globalStore.bootstrap().then((res) => {
if (companyStore.isAdminMode) {
return
}
if (!res.current_company) {
if (route.name !== 'no.company') {
router.push({ name: 'no.company' })
}
return
}
const meta = route.meta as RouteMeta
if (meta.ability && !userStore.hasAbilities(meta.ability as string | string[])) {
router.push({ name: 'account.settings' })
} else if (meta.isSuperAdmin && !userStore.currentUser?.is_super_admin) {
router.push({ name: 'dashboard' })
} else if (meta.isOwner && !userStore.currentUser?.is_owner) {
router.push({ name: 'account.settings' })
}
if (
companyStore.selectedCompanySettings.bulk_exchange_rate_configured === 'NO'
) {
modalStore.openModal({
componentName: 'ExchangeRateBulkUpdateModal',
title: t('exchange_rates.bulk_update'),
size: 'sm',
})
}
})
})
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="h-screen overflow-y-auto text-base">
<NotificationRoot />
<div class="container mx-auto px-4">
<router-view />
</div>
</div>
</template>
<script setup lang="ts">
import NotificationRoot from '../../components/notifications/NotificationRoot.vue'
</script>

View File

@@ -0,0 +1,8 @@
export { default as CompanyLayout } from './CompanyLayout.vue'
export { default as AuthLayout } from './AuthLayout.vue'
export { default as InstallationLayout } from './InstallationLayout.vue'
export { default as SiteHeader } from './partials/SiteHeader.vue'
export { default as SiteSidebar } from './partials/SiteSidebar.vue'
export { default as CompanySwitcher } from './partials/CompanySwitcher.vue'
export { default as GlobalSearchBar } from './partials/GlobalSearchBar.vue'
export { default as ImpersonationBanner } from './partials/ImpersonationBanner.vue'

View File

@@ -0,0 +1,271 @@
<template>
<div ref="companySwitchBar" class="relative rounded">
<div
class="
flex items-center justify-center px-3 h-8 md:h-9 ml-2 text-sm text-white
bg-white/20 rounded-lg cursor-pointer hover:bg-white/30 transition-colors
"
@click="isShow = !isShow"
>
<span
v-if="companyStore.isAdminMode"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ $t('navigation.administration') }}
</span>
<span
v-else-if="companyStore.selectedCompany"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ companyStore.selectedCompany.name }}
</span>
<BaseIcon name="ChevronDownIcon" class="h-5 ml-1 text-white" />
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="absolute right-0 mt-2 bg-surface rounded-md shadow-lg"
>
<div
class="
overflow-y-auto scrollbar-thin scrollbar-thumb-rounded-full
w-[300px] max-h-[350px]
scrollbar-thumb-surface-muted scrollbar-track-surface-secondary pb-4
"
>
<!-- Administration Mode -->
<div v-if="userStore.currentUser?.is_super_admin">
<div
class="p-2 px-3 rounded-md cursor-pointer hover:bg-hover-strong hover:text-primary-500"
:class="{
'bg-surface-tertiary text-primary-500': companyStore.isAdminMode,
}"
@click="enterAdminMode"
>
<div class="flex items-center">
<span
class="flex items-center justify-center mr-3 overflow-hidden text-base font-semibold bg-primary-100 rounded-md w-9 h-9 shrink-0 text-primary-500"
>
<BaseIcon name="ShieldCheckIcon" class="w-5 h-5" />
</span>
<div class="flex flex-col">
<span class="text-sm font-medium">{{ $t('navigation.administration') }}</span>
</div>
</div>
</div>
<div class="border-t border-line-light my-1" />
</div>
<label
class="px-3 py-2 text-xs font-semibold text-subtle mb-0.5 block uppercase"
>
{{ $t('company_switcher.label') }}
</label>
<div
v-if="companyStore.companies.length < 1"
class="flex flex-col items-center justify-center p-2 px-3 mt-4 text-base text-subtle"
>
<BaseIcon name="ExclamationCircleIcon" class="h-5 text-subtle" />
{{ $t('company_switcher.no_results_found') }}
</div>
<div v-else>
<div v-if="companyStore.companies.length > 0">
<div
v-for="(company, index) in companyStore.companies"
:key="index"
class="p-2 px-3 rounded-md cursor-pointer hover:bg-hover-strong hover:text-primary-500"
:class="{
'bg-surface-tertiary text-primary-500':
companyStore.selectedCompany && companyStore.selectedCompany.id === company.id,
}"
@click="changeCompany(company)"
>
<div class="flex items-center">
<span
class="
flex items-center justify-center mr-3 overflow-hidden text-base font-semibold
bg-surface-muted rounded-md w-9 h-9 text-primary-500
"
>
<span v-if="!company.logo">
{{ initGenerator(company.name) }}
</span>
<img
v-else
:src="company.logo"
alt="Company logo"
class="w-full h-full object-contain"
/>
</span>
<div class="flex flex-col">
<span class="text-sm">{{ company.name }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Pending Invitations -->
<div
v-if="pendingInvitations.length > 0"
class="border-t border-line-light p-2"
>
<label
class="block px-1 pt-1 pb-2 text-xs font-semibold leading-tight text-subtle uppercase"
>
{{ $t('members.pending_invitations') }}
</label>
<div
v-for="invitation in pendingInvitations"
:key="invitation.id"
class="p-2 px-3 rounded-md"
>
<div class="flex items-center mb-2">
<span
class="
flex items-center justify-center mr-3 overflow-hidden text-xs font-semibold
bg-surface-muted rounded-md w-9 h-9 shrink-0 text-subtle
"
>
{{ initGenerator(invitation.company?.name ?? '?') }}
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm text-body truncate">{{ invitation.company?.name }}</span>
<span class="text-xs text-subtle">{{ invitation.role?.title }}</span>
</div>
</div>
<div class="flex space-x-1 pl-12">
<button
class="text-xs px-2 py-1 rounded bg-primary-500 text-white hover:bg-primary-600"
@click.stop="acceptInvitation(invitation.token)"
>
{{ $t('general.accept') }}
</button>
<button
class="text-xs px-2 py-1 rounded bg-surface-muted text-body hover:bg-hover-strong"
@click.stop="declineInvitation(invitation.token)"
>
{{ $t('general.decline') }}
</button>
</div>
</div>
</div>
<div
v-if="userStore.currentUser?.is_owner"
class="
flex items-center justify-center p-4 pl-3 border-t-2 border-line-light
cursor-pointer text-primary-400 hover:text-primary-500
"
@click="addNewCompany"
>
<BaseIcon name="PlusIcon" class="h-5 mr-2" />
<span class="font-medium">
{{ $t('company_switcher.add_new_company') }}
</span>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '../../../stores/company.store'
import { useGlobalStore } from '../../../stores/global.store'
import { useUserStore } from '../../../stores/user.store'
import { useModalStore } from '../../../stores/modal.store'
import type { Company, CompanyInvitation } from '../../../types/domain/company'
import type { Role } from '../../../types/domain/role'
interface PendingInvitation {
id: number
token: string
company?: { name: string }
role?: { title: string }
}
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const { t } = useI18n()
const userStore = useUserStore()
const isShow = ref<boolean>(false)
const companySwitchBar = ref<HTMLElement | null>(null)
// TODO: Wire up pending invitations from a dedicated invitation store or bootstrap data
const pendingInvitations = ref<PendingInvitation[]>([])
watch(route, () => {
isShow.value = false
})
onClickOutside(companySwitchBar, () => {
isShow.value = false
})
function initGenerator(name: string): string {
if (name) {
const nameSplit = name.split(' ')
return nameSplit[0].charAt(0).toUpperCase()
}
return ''
}
function addNewCompany(): void {
modalStore.openModal({
title: t('company_switcher.new_company'),
componentName: 'CompanyModal',
size: 'sm',
})
}
async function enterAdminMode(): Promise<void> {
companyStore.setAdminMode(true)
isShow.value = false
router.push('/admin/administration/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function changeCompany(company: Company): Promise<void> {
companyStore.setAdminMode(false)
companyStore.setSelectedCompany(company)
router.push('/admin/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function acceptInvitation(token: string): Promise<void> {
// TODO: call invitation accept API, then re-bootstrap
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.token !== token
)
await globalStore.bootstrap()
}
async function declineInvitation(token: string): Promise<void> {
// TODO: call invitation decline API
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.token !== token
)
}
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div ref="searchBar" class="hidden rounded md:block relative">
<div>
<BaseInput
v-model="searchQuery"
:placeholder="$t('global_search.search')"
container-class="!rounded-lg !shadow-none"
class="h-8 md:h-9 !rounded-lg !bg-white/20 !border-white/10 !text-white !placeholder-white/60"
@input="onSearchInput"
>
<template #left>
<BaseIcon name="MagnifyingGlassIcon" class="!text-white/70" />
</template>
<template #right>
<span v-if="isSearching" class="h-5 w-5 animate-spin text-primary-500" />
</template>
</BaseInput>
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="
scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-surface-muted
scrollbar-track-surface-secondary overflow-y-auto bg-surface rounded-md
mt-2 shadow-lg p-3 absolute w-[300px] h-[200px] right-0
"
>
<div
v-if="customerList.length < 1 && userList.length < 1"
class="flex items-center justify-center text-subtle text-base flex-col mt-4"
>
<BaseIcon name="ExclamationCircleIcon" class="text-subtle" />
{{ $t('global_search.no_results_found') }}
</div>
<div v-else>
<div v-if="customerList.length > 0">
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
{{ $t('global_search.customers') }}
</label>
<div
v-for="(customer, index) in customerList"
:key="index"
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
>
<router-link
:to="{ path: `/admin/customers/${customer.id}/view` }"
class="flex items-center"
>
<span
class="
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
bg-surface-muted rounded-full text-primary-500
"
>
{{ initGenerator(customer.name) }}
</span>
<div class="flex flex-col">
<span class="text-sm">{{ customer.name }}</span>
<span v-if="customer.contact_name" class="text-xs text-subtle">
{{ customer.contact_name }}
</span>
<span v-else class="text-xs text-subtle">{{ customer.email }}</span>
</div>
</router-link>
</div>
</div>
<div v-if="userList.length > 0" class="mt-2">
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
{{ $t('global_search.users') }}
</label>
<div
v-for="(user, index) in userList"
:key="index"
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
>
<router-link
:to="{ path: `/admin/members/${user.id}/edit` }"
class="flex items-center"
>
<span
class="
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
bg-surface-muted rounded-full text-primary-500
"
>
{{ initGenerator(user.name) }}
</span>
<div class="flex flex-col">
<span class="text-sm">{{ user.name }}</span>
<span class="text-xs text-subtle">{{ user.email }}</span>
</div>
</router-link>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onClickOutside, useDebounceFn } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
interface SearchResult {
id: number
name: string
email?: string
contact_name?: string
}
const isShow = ref<boolean>(false)
const searchQuery = ref<string>('')
const searchBar = ref<HTMLElement | null>(null)
const isSearching = ref<boolean>(false)
const customerList = ref<SearchResult[]>([])
const userList = ref<SearchResult[]>([])
const route = useRoute()
watch(route, () => {
isShow.value = false
searchQuery.value = ''
})
onClickOutside(searchBar, () => {
isShow.value = false
searchQuery.value = ''
})
const debouncedSearch = useDebounceFn(async () => {
if (!searchQuery.value) {
isShow.value = false
return
}
isSearching.value = true
try {
const { data } = await client.get(API.SEARCH, {
params: { search: searchQuery.value },
})
customerList.value = data.customers ?? []
userList.value = data.users ?? []
isShow.value = true
} finally {
isSearching.value = false
}
}, 500)
function onSearchInput(): void {
if (searchQuery.value === '') {
isShow.value = false
return
}
debouncedSearch()
}
function initGenerator(name: string): string {
if (name) {
const nameSplit = name.split(' ')
return nameSplit[0].charAt(0).toUpperCase()
}
return ''
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="isImpersonating"
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-orange-600"
>
<BaseIcon name="ExclamationTriangleIcon" class="w-4 h-4 mr-2" />
<span>{{ $t('administration.users.impersonating_banner') }}</span>
<button
class="ml-4 px-3 py-1 text-xs font-semibold text-orange-600 bg-white rounded hover:bg-orange-50"
:disabled="isStopping"
@click="stopImpersonating"
>
{{ $t('administration.users.stop_impersonating') }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import * as ls from '../../../utils/local-storage'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
const isStopping = ref<boolean>(false)
const isImpersonating = computed<boolean>(() => {
return ls.get<string>('admin.impersonating') === 'true'
})
async function stopImpersonating(): Promise<void> {
isStopping.value = true
try {
await client.post(API.SUPER_ADMIN_STOP_IMPERSONATING)
} catch {
// Token already cleaned up in store action
}
ls.remove('admin.impersonating')
ls.remove('auth.token')
window.location.href = '/admin/administration/users'
}
</script>

View File

@@ -0,0 +1,229 @@
<template>
<header
class="
fixed top-0 left-0 z-20 flex items-center justify-between w-full
px-4 py-3 md:h-16 md:px-8 bg-linear-to-r from-header-from to-header-to
"
>
<div class="flex items-center">
<router-link
:to="companyStore.isAdminMode ? '/admin/administration/dashboard' : '/admin/dashboard'"
class="
text-lg not-italic font-black tracking-wider text-white
brand-main font-base hidden md:block
"
>
<img v-if="adminLogo" :src="adminLogo" class="h-6" />
<MainLogo v-else class="h-6" light-color="white" dark-color="white" />
</router-link>
</div>
<!-- Mobile toggle button -->
<div
:class="{ 'is-active': globalStore.isSidebarOpen }"
class="
flex float-left p-1 overflow-visible text-sm ease-linear bg-surface
border-0 rounded cursor-pointer md:hidden md:ml-0 hover:bg-hover-strong
"
@click.prevent="onToggle"
>
<BaseIcon name="Bars3Icon" class="!w-6 !h-6 text-muted" />
</div>
<ul class="flex float-right h-8 m-0 list-none md:h-9">
<!-- Create dropdown -->
<li
v-if="hasCreateAbilities && !companyStore.isAdminMode"
class="relative hidden float-left m-0 md:block"
>
<BaseDropdown width-class="w-48">
<template #activator>
<div
class="
flex items-center justify-center w-8 h-8 ml-2 text-sm text-white
bg-white/20 rounded-lg hover:bg-white/30 md:h-9 md:w-9
"
>
<BaseIcon name="PlusIcon" class="w-5 h-5 text-white" />
</div>
</template>
<router-link to="/admin/invoices/create">
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.CREATE_INVOICE)"
>
<BaseIcon
name="DocumentTextIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('invoices.new_invoice') }}
</BaseDropdownItem>
</router-link>
<router-link to="/admin/estimates/create">
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.CREATE_ESTIMATE)"
>
<BaseIcon
name="DocumentIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('estimates.new_estimate') }}
</BaseDropdownItem>
</router-link>
<router-link to="/admin/customers/create">
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOMER)"
>
<BaseIcon
name="UserIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('customers.new_customer') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</li>
<!-- Global search -->
<li v-if="!companyStore.isAdminMode" class="ml-2">
<GlobalSearchBar
v-if="
userStore.currentUser?.is_owner ||
userStore.hasAbilities(ABILITIES.VIEW_CUSTOMER)
"
/>
</li>
<!-- Company switcher -->
<li>
<CompanySwitcher />
</li>
<!-- User dropdown -->
<li class="relative block float-left ml-2">
<BaseDropdown width-class="w-48">
<template #activator>
<img
:src="previewAvatar"
class="block w-8 h-8 rounded-full ring-2 ring-white/30 md:h-9 md:w-9 object-cover"
/>
</template>
<!-- Theme Toggle -->
<div class="px-3 py-2">
<div class="flex items-center justify-between rounded-lg bg-surface-secondary p-1">
<button
v-for="opt in themeOptions"
:key="opt.value"
:class="[
'flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
currentTheme === opt.value
? 'bg-surface text-heading shadow-sm'
: 'text-muted hover:text-body',
]"
@click.stop="setTheme(opt.value)"
>
<BaseIcon :name="opt.icon" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<router-link to="/admin/user-settings">
<BaseDropdownItem>
<BaseIcon
name="CogIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('navigation.settings') }}
</BaseDropdownItem>
</router-link>
<BaseDropdownItem @click="logout">
<BaseIcon
name="ArrowRightOnRectangleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('navigation.logout') }}
</BaseDropdownItem>
</BaseDropdown>
</li>
</ul>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../../stores/auth.store'
import { useUserStore } from '../../../stores/user.store'
import { useGlobalStore } from '../../../stores/global.store'
import { useCompanyStore } from '../../../stores/company.store'
import { useTheme } from '../../../composables/use-theme'
import { ABILITIES } from '../../../config/abilities'
import { THEME } from '../../../config/constants'
import type { Theme } from '../../../config/constants'
import CompanySwitcher from './CompanySwitcher.vue'
import GlobalSearchBar from './GlobalSearchBar.vue'
import MainLogo from '../../../components/icons/MainLogo.vue'
interface ThemeOption {
value: Theme
icon: string
}
const authStore = useAuthStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const router = useRouter()
const { currentTheme, setTheme } = useTheme()
const previewAvatar = computed<string>(() => {
if (userStore.currentUser && userStore.currentUser.avatar !== 0) {
return userStore.currentUser.avatar as string
}
return getDefaultAvatar()
})
const adminLogo = computed<string | false>(() => {
if (globalStore.globalSettings?.admin_portal_logo) {
return '/storage/' + globalStore.globalSettings.admin_portal_logo
}
return false
})
const hasCreateAbilities = computed<boolean>(() => {
return userStore.hasAbilities([
ABILITIES.CREATE_INVOICE,
ABILITIES.CREATE_ESTIMATE,
ABILITIES.CREATE_CUSTOMER,
])
})
function getDefaultAvatar(): string {
const imgUrl = new URL('$images/default-avatar.jpg', import.meta.url)
return imgUrl.href
}
async function logout(): Promise<void> {
await authStore.logout()
router.push('/login')
}
function onToggle(): void {
globalStore.setSidebarVisibility(true)
}
const themeOptions: ThemeOption[] = [
{ value: THEME.LIGHT, icon: 'SunIcon' },
{ value: THEME.DARK, icon: 'MoonIcon' },
{ value: THEME.SYSTEM, icon: 'ComputerDesktopIcon' },
]
</script>

View File

@@ -0,0 +1,217 @@
<template>
<!-- MOBILE MENU -->
<TransitionRoot as="template" :show="globalStore.isSidebarOpen">
<Dialog
as="div"
class="fixed inset-0 z-40 flex md:hidden"
@close="globalStore.setSidebarVisibility(false)"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay class="fixed inset-0 bg-gray-600/75" />
</TransitionChild>
<TransitionChild
as="template"
enter="transition ease-in-out duration-300"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<div class="relative flex flex-col flex-1 w-full max-w-xs bg-surface">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="absolute top-0 right-0 pt-2 -mr-12">
<button
class="
flex items-center justify-center w-10 h-10 ml-1 rounded-full
focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-white
"
@click="globalStore.setSidebarVisibility(false)"
>
<span class="sr-only">Close sidebar</span>
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</TransitionChild>
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div class="flex items-center shrink-0 px-4 mb-10">
<MainLogo
class="block h-auto max-w-full w-36 text-primary-400"
alt="InvoiceShelf Logo"
/>
</div>
<nav
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="mt-5 space-y-1"
>
<div
v-if="menu[0] && menu[0].group_label"
class="px-4 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider"
>
{{ $t(menu[0].group_label) }}
</div>
<router-link
v-for="item in menu"
:key="item.name"
:to="item.link"
:class="[
hasActiveUrl(item.link)
? 'text-primary-600 bg-primary-50 font-semibold'
: 'text-body hover:bg-hover',
'cursor-pointer mx-3 px-3 py-2.5 flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
]"
@click="globalStore.setSidebarVisibility(false)"
>
<BaseIcon
:name="item.icon"
:class="[
hasActiveUrl(item.link)
? 'text-primary-500'
: 'text-subtle',
'mr-3 shrink-0 h-5 w-5',
]"
@click="globalStore.setSidebarVisibility(false)"
/>
{{ $t(item.title) }}
</router-link>
</nav>
</div>
</div>
</TransitionChild>
<div class="shrink-0 w-14">
<!-- Force sidebar to shrink to fit close icon -->
</div>
</Dialog>
</TransitionRoot>
<!-- DESKTOP MENU -->
<div
:class="[
globalStore.isSidebarCollapsed ? 'w-16' : 'w-56 xl:w-64',
]"
class="
hidden h-screen pb-0 overflow-y-auto overflow-x-hidden
bg-surface/80 backdrop-blur-xl border-r border-white/10
md:fixed md:flex md:flex-col md:inset-y-0 pt-16
transition-all duration-300
"
>
<div
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="p-0 m-0 mt-4 list-none"
>
<div
v-if="menu[0] && menu[0].group_label && !globalStore.isSidebarCollapsed"
class="px-6 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider whitespace-nowrap"
>
{{ $t(menu[0].group_label) }}
</div>
<div
v-else-if="menu[0] && menu[0].group_label && globalStore.isSidebarCollapsed"
class="mx-3 my-2 border-t border-line-light"
/>
<router-link
v-for="item in menu"
:key="item.name"
:to="item.link"
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t(item.title), placement: 'right' } : null"
:class="[
hasActiveUrl(item.link)
? 'text-primary-600 bg-primary-50 font-semibold'
: 'text-body hover:bg-hover',
globalStore.isSidebarCollapsed
? 'cursor-pointer mx-2 px-0 py-2.5 group flex items-center justify-center rounded-lg text-sm font-medium transition-colors'
: 'cursor-pointer mx-3 px-3 py-2.5 group flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
]"
>
<BaseIcon
:name="item.icon"
:class="[
hasActiveUrl(item.link)
? 'text-primary-500'
: 'text-subtle group-hover:text-body',
globalStore.isSidebarCollapsed
? 'shrink-0 h-6 w-6'
: 'mr-3 shrink-0 h-5 w-5',
]"
/>
<span v-if="!globalStore.isSidebarCollapsed" class="whitespace-nowrap">
{{ $t(item.title) }}
</span>
</router-link>
</div>
<!-- Bottom toolbar -->
<div class="mt-auto sticky bottom-0 border-t border-white/10 bg-surface/80 backdrop-blur-xl p-2 flex flex-col items-center gap-1">
<button
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t('general.collapse'), placement: 'right' } : null"
:class="[
globalStore.isSidebarCollapsed
? 'w-10 h-10 justify-center'
: 'w-full px-3 h-10 justify-end',
]"
class="flex items-center rounded-lg text-subtle hover:text-body hover:bg-hover transition-colors"
@click="globalStore.toggleSidebarCollapse()"
>
<BaseIcon
:name="globalStore.isSidebarCollapsed ? 'ChevronDoubleRightIcon' : 'ChevronDoubleLeftIcon'"
class="w-4 h-4 shrink-0"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogOverlay,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue'
import { useRoute } from 'vue-router'
import { useGlobalStore } from '../../../stores/global.store'
import MainLogo from '../../../components/icons/MainLogo.vue'
interface MenuItemData {
name: string
title: string
icon: string
link: string
group_label?: string
}
const route = useRoute()
const globalStore = useGlobalStore()
function hasActiveUrl(url: string): boolean {
return route.path.indexOf(url) > -1
}
</script>