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:
@@ -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>
|
||||
@@ -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>
|
||||
20
resources/scripts-v2/features/admin/index.ts
Normal file
20
resources/scripts-v2/features/admin/index.ts
Normal 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'
|
||||
113
resources/scripts-v2/features/admin/routes.ts
Normal file
113
resources/scripts-v2/features/admin/routes.ts
Normal 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 />' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
203
resources/scripts-v2/features/admin/stores/admin.store.ts
Normal file
203
resources/scripts-v2/features/admin/stores/admin.store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
210
resources/scripts-v2/features/admin/views/AdminCompaniesView.vue
Normal file
210
resources/scripts-v2/features/admin/views/AdminCompaniesView.vue
Normal 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>
|
||||
@@ -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>
|
||||
105
resources/scripts-v2/features/admin/views/AdminDashboardView.vue
Normal file
105
resources/scripts-v2/features/admin/views/AdminDashboardView.vue
Normal 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>
|
||||
117
resources/scripts-v2/features/admin/views/AdminSettingsView.vue
Normal file
117
resources/scripts-v2/features/admin/views/AdminSettingsView.vue
Normal 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>
|
||||
187
resources/scripts-v2/features/admin/views/AdminUserEditView.vue
Normal file
187
resources/scripts-v2/features/admin/views/AdminUserEditView.vue
Normal 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>
|
||||
271
resources/scripts-v2/features/admin/views/AdminUsersView.vue
Normal file
271
resources/scripts-v2/features/admin/views/AdminUsersView.vue
Normal 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>
|
||||
6
resources/scripts-v2/features/auth/index.ts
Normal file
6
resources/scripts-v2/features/auth/index.ts
Normal 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'
|
||||
52
resources/scripts-v2/features/auth/routes.ts
Normal file
52
resources/scripts-v2/features/auth/routes.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
]
|
||||
101
resources/scripts-v2/features/auth/views/ForgotPasswordView.vue
Normal file
101
resources/scripts-v2/features/auth/views/ForgotPasswordView.vue
Normal 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>
|
||||
129
resources/scripts-v2/features/auth/views/LoginView.vue
Normal file
129
resources/scripts-v2/features/auth/views/LoginView.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<form id="loginForm" class="mt-12 text-left" @submit.prevent="onSubmit">
|
||||
<BaseInputGroup
|
||||
:error="v$.email.$error && v$.email.$errors[0].$message"
|
||||
:label="$t('login.email')"
|
||||
class="mb-4"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="authStore.loginData.email"
|
||||
:invalid="v$.email.$error"
|
||||
focus
|
||||
type="email"
|
||||
name="email"
|
||||
@input="v$.email.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:error="v$.password.$error && v$.password.$errors[0].$message"
|
||||
:label="$t('login.password')"
|
||||
class="mb-4"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="authStore.loginData.password"
|
||||
:invalid="v$.password.$error"
|
||||
:type="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>
|
||||
@@ -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>
|
||||
165
resources/scripts-v2/features/auth/views/ResetPasswordView.vue
Normal file
165
resources/scripts-v2/features/auth/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<form id="loginForm" @submit.prevent="onSubmit">
|
||||
<BaseInputGroup
|
||||
:error="errorEmail"
|
||||
:label="$t('login.email')"
|
||||
class="mb-4"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.email"
|
||||
:invalid="v$.email.$error"
|
||||
focus
|
||||
type="email"
|
||||
name="email"
|
||||
@input="v$.email.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:error="errorPassword"
|
||||
:label="$t('login.password')"
|
||||
class="mb-4"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.password"
|
||||
:invalid="v$.password.$error"
|
||||
type="password"
|
||||
name="password"
|
||||
@input="v$.password.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:error="errorConfirmPassword"
|
||||
:label="$t('login.retype_password')"
|
||||
class="mb-4"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.password_confirmation"
|
||||
:invalid="v$.password_confirmation.$error"
|
||||
type="password"
|
||||
name="password_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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
7
resources/scripts-v2/features/company/customers/index.ts
Normal file
7
resources/scripts-v2/features/company/customers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { useCustomerStore } from './store'
|
||||
export type {
|
||||
CustomerForm,
|
||||
CustomerFormAddress,
|
||||
CustomerViewData,
|
||||
} from './store'
|
||||
export { default as customerRoutes } from './routes'
|
||||
38
resources/scripts-v2/features/company/customers/routes.ts
Normal file
38
resources/scripts-v2/features/company/customers/routes.ts
Normal 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
|
||||
335
resources/scripts-v2/features/company/customers/store.ts
Normal file
335
resources/scripts-v2/features/company/customers/store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
8
resources/scripts-v2/features/company/dashboard/index.ts
Normal file
8
resources/scripts-v2/features/company/dashboard/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useDashboardStore } from './store'
|
||||
export type {
|
||||
DashboardStats,
|
||||
DashboardChartData,
|
||||
DueInvoice,
|
||||
RecentEstimate,
|
||||
} from './store'
|
||||
export { default as dashboardRoutes } from './routes'
|
||||
14
resources/scripts-v2/features/company/dashboard/routes.ts
Normal file
14
resources/scripts-v2/features/company/dashboard/routes.ts
Normal 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
|
||||
137
resources/scripts-v2/features/company/dashboard/store.ts
Normal file
137
resources/scripts-v2/features/company/dashboard/store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
12
resources/scripts-v2/features/company/estimates/index.ts
Normal file
12
resources/scripts-v2/features/company/estimates/index.ts
Normal 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'
|
||||
44
resources/scripts-v2/features/company/estimates/routes.ts
Normal file
44
resources/scripts-v2/features/company/estimates/routes.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
]
|
||||
548
resources/scripts-v2/features/company/estimates/store.ts
Normal file
548
resources/scripts-v2/features/company/estimates/store.ts
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
13
resources/scripts-v2/features/company/invoices/index.ts
Normal file
13
resources/scripts-v2/features/company/invoices/index.ts
Normal 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'
|
||||
44
resources/scripts-v2/features/company/invoices/routes.ts
Normal file
44
resources/scripts-v2/features/company/invoices/routes.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
]
|
||||
539
resources/scripts-v2/features/company/invoices/store.ts
Normal file
539
resources/scripts-v2/features/company/invoices/store.ts
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
6
resources/scripts-v2/features/company/items/index.ts
Normal file
6
resources/scripts-v2/features/company/items/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useItemStore } from './store'
|
||||
export type {
|
||||
ItemForm,
|
||||
ItemUnitForm,
|
||||
} from './store'
|
||||
export { default as itemRoutes } from './routes'
|
||||
30
resources/scripts-v2/features/company/items/routes.ts
Normal file
30
resources/scripts-v2/features/company/items/routes.ts
Normal 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
|
||||
316
resources/scripts-v2/features/company/items/store.ts
Normal file
316
resources/scripts-v2/features/company/items/store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
25
resources/scripts-v2/features/shared/document-form/index.ts
Normal file
25
resources/scripts-v2/features/shared/document-form/index.ts
Normal 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'
|
||||
@@ -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)
|
||||
}
|
||||
134
resources/scripts-v2/layouts/AuthLayout.vue
Normal file
134
resources/scripts-v2/layouts/AuthLayout.vue
Normal 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>
|
||||
99
resources/scripts-v2/layouts/CompanyLayout.vue
Normal file
99
resources/scripts-v2/layouts/CompanyLayout.vue
Normal 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>
|
||||
13
resources/scripts-v2/layouts/InstallationLayout.vue
Normal file
13
resources/scripts-v2/layouts/InstallationLayout.vue
Normal 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>
|
||||
8
resources/scripts-v2/layouts/index.ts
Normal file
8
resources/scripts-v2/layouts/index.ts
Normal 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'
|
||||
271
resources/scripts-v2/layouts/partials/CompanySwitcher.vue
Normal file
271
resources/scripts-v2/layouts/partials/CompanySwitcher.vue
Normal 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>
|
||||
179
resources/scripts-v2/layouts/partials/GlobalSearchBar.vue
Normal file
179
resources/scripts-v2/layouts/partials/GlobalSearchBar.vue
Normal 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>
|
||||
@@ -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>
|
||||
229
resources/scripts-v2/layouts/partials/SiteHeader.vue
Normal file
229
resources/scripts-v2/layouts/partials/SiteHeader.vue
Normal 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>
|
||||
217
resources/scripts-v2/layouts/partials/SiteSidebar.vue
Normal file
217
resources/scripts-v2/layouts/partials/SiteSidebar.vue
Normal 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>
|
||||
Reference in New Issue
Block a user