Phase 4b: Remaining features — payments, expenses, recurring

invoices, members, reports, settings, customer portal, modules,
installation

82 files, 14293 lines. Completes all feature modules:
- payments: CRUD with send/preview, payment modes
- expenses: CRUD with receipt upload, categories
- recurring-invoices: full frequency logic, limit by date/count
- members: list with roles, invite modal, pending invitations
- reports: sales, profit/loss, expenses, tax with date ranges
- settings: 14 settings views, number customizer, mail config
- customer-portal: consolidated store, 8 views, portal layout
- modules: marketplace index, detail/install, module cards
- installation: 8-step wizard with requirements/db/mail/account

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

View File

@@ -0,0 +1,82 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Category -->
<BaseDropdownItem v-if="canEdit" @click="editExpenseCategory">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<!-- Delete Category -->
<BaseDropdownItem v-if="canDelete" @click="removeExpenseCategory">
<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 { useI18n } from 'vue-i18n'
import type { ExpenseCategory } from '../../../../types/domain/expense'
interface TableRef {
refresh: () => void
}
interface Props {
row: ExpenseCategory
table?: TableRef | null
loadData?: (() => void) | null
canEdit?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<Props>(), {
table: null,
loadData: null,
canEdit: false,
canDelete: false,
})
const { t } = useI18n()
function editExpenseCategory(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('settings.expense_category.edit_category'),
componentName: 'CategoryModal',
refreshData: props.loadData,
size: 'sm',
})
}
async function removeExpenseCategory(): Promise<void> {
const confirmed = window.confirm(
t('settings.expense_category.confirm_delete'),
)
if (!confirmed) return
try {
const { expenseService } = await import(
'../../../../api/services/expense.service'
)
const response = await expenseService.deleteCategory(props.row.id)
if (response.success) {
props.loadData?.()
}
} catch {
props.loadData?.()
}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Expense -->
<router-link v-if="canEdit" :to="`/admin/expenses/${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 Expense -->
<BaseDropdownItem v-if="canDelete" @click="removeExpense">
<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 { useI18n } from 'vue-i18n'
import { useExpenseStore } from '../store'
import type { Expense } from '../../../../types/domain/expense'
interface TableRef {
refresh: () => void
}
interface Props {
row: Expense
table?: TableRef | null
loadData?: (() => void) | null
canEdit?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<Props>(), {
table: null,
loadData: null,
canEdit: false,
canDelete: false,
})
const expenseStore = useExpenseStore()
const { t } = useI18n()
async function removeExpense(): Promise<void> {
const confirmed = window.confirm(t('expenses.confirm_delete'))
if (!confirmed) return
const res = await expenseStore.deleteExpense({ ids: [props.row.id] })
if (res) {
props.loadData?.()
}
}
</script>

View File

@@ -0,0 +1,11 @@
export { useExpenseStore } from './store'
export type { ExpenseStore, ExpenseFormData, ExpenseState } from './store'
export { expenseRoutes } from './routes'
// Views
export { default as ExpenseIndexView } from './views/ExpenseIndexView.vue'
export { default as ExpenseCreateView } from './views/ExpenseCreateView.vue'
// Components
export { default as ExpenseDropdown } from './components/ExpenseDropdown.vue'
export { default as ExpenseCategoryDropdown } from './components/ExpenseCategoryDropdown.vue'

View File

@@ -0,0 +1,34 @@
import type { RouteRecordRaw } from 'vue-router'
const ExpenseIndexView = () => import('./views/ExpenseIndexView.vue')
const ExpenseCreateView = () => import('./views/ExpenseCreateView.vue')
export const expenseRoutes: RouteRecordRaw[] = [
{
path: 'expenses',
name: 'expenses.index',
component: ExpenseIndexView,
meta: {
ability: 'view-expense',
title: 'expenses.title',
},
},
{
path: 'expenses/create',
name: 'expenses.create',
component: ExpenseCreateView,
meta: {
ability: 'create-expense',
title: 'expenses.new_expense',
},
},
{
path: 'expenses/:id/edit',
name: 'expenses.edit',
component: ExpenseCreateView,
meta: {
ability: 'edit-expense',
title: 'expenses.edit_expense',
},
},
]

View File

@@ -0,0 +1,253 @@
import { defineStore } from 'pinia'
import { expenseService } from '../../../api/services/expense.service'
import type {
ExpenseListParams,
ExpenseListResponse,
} from '../../../api/services/expense.service'
import type {
Expense,
ExpenseCategory,
CreateExpensePayload,
} from '../../../types/domain/expense'
import type { PaymentMethod } from '../../../types/domain/payment'
import type { Currency } from '../../../types/domain/currency'
import type { CustomFieldValue } from '../../../types/domain/custom-field'
// ----------------------------------------------------------------
// Stub factories
// ----------------------------------------------------------------
export interface ReceiptFile {
image?: string
type?: string
name?: string
}
export interface ExpenseFormData {
id: number | null
expense_date: string
expense_number: string
amount: number
notes: string | null
customer_id: number | null
expense_category_id: number | null
payment_method_id: number | null
currency_id: number | null
exchange_rate: number | null
selectedCurrency: Currency | null
attachment_receipt: File | null
attachment_receipt_url: string | null
receiptFiles: ReceiptFile[]
customFields: CustomFieldValue[]
fields: CustomFieldValue[]
}
function createExpenseStub(): ExpenseFormData {
return {
id: null,
expense_date: '',
expense_number: '',
amount: 0,
notes: '',
customer_id: null,
expense_category_id: null,
payment_method_id: null,
currency_id: null,
exchange_rate: null,
selectedCurrency: null,
attachment_receipt: null,
attachment_receipt_url: null,
receiptFiles: [],
customFields: [],
fields: [],
}
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface ExpenseState {
expenses: Expense[]
totalExpenses: number
selectAllField: boolean
selectedExpenses: number[]
paymentModes: PaymentMethod[]
showExchangeRate: boolean
currentExpense: ExpenseFormData
}
export const useExpenseStore = defineStore('expense', {
state: (): ExpenseState => ({
expenses: [],
totalExpenses: 0,
selectAllField: false,
selectedExpenses: [],
paymentModes: [],
showExchangeRate: false,
currentExpense: createExpenseStub(),
}),
getters: {
getCurrentExpense: (state): ExpenseFormData => state.currentExpense,
getSelectedExpenses: (state): number[] => state.selectedExpenses,
},
actions: {
resetCurrentExpenseData(): void {
this.currentExpense = createExpenseStub()
},
async fetchExpenses(
params: ExpenseListParams,
): Promise<{ data: ExpenseListResponse }> {
const response = await expenseService.list(params)
this.expenses = response.data
this.totalExpenses = response.meta.expense_total_count
return { data: response }
},
async fetchExpense(id: number): Promise<{ data: { data: Expense } }> {
const response = await expenseService.get(id)
const data = response.data
Object.assign(this.currentExpense, data)
this.currentExpense.selectedCurrency = data.currency ?? null
this.currentExpense.attachment_receipt = null
if (data.attachment_receipt_url) {
if (
data.attachment_receipt_meta?.mime_type?.startsWith('image/')
) {
this.currentExpense.receiptFiles = [
{
image: `/reports/expenses/${id}/receipt?${data.attachment_receipt_meta.uuid}`,
},
]
} else if (data.attachment_receipt_meta) {
this.currentExpense.receiptFiles = [
{
type: 'document',
name: data.attachment_receipt_meta.file_name,
},
]
}
} else {
this.currentExpense.receiptFiles = []
}
return { data: response }
},
async addExpense(
data: Record<string, unknown>,
): Promise<{ data: { data: Expense } }> {
const formData = toFormData(data)
const response = await expenseService.create(formData)
this.expenses.push(response.data)
return { data: response }
},
async updateExpense(params: {
id: number
data: Record<string, unknown>
isAttachmentReceiptRemoved: boolean
}): Promise<{ data: { data: Expense } }> {
const formData = toFormData(params.data)
formData.append('_method', 'PUT')
formData.append(
'is_attachment_receipt_removed',
String(params.isAttachmentReceiptRemoved),
)
const response = await expenseService.update(params.id, formData)
const pos = this.expenses.findIndex((e) => e.id === response.data.id)
if (pos !== -1) {
this.expenses[pos] = response.data
}
return { data: response }
},
async deleteExpense(
payload: { ids: number[] },
): Promise<{ data: { success: boolean } }> {
const response = await expenseService.delete(payload)
const id = payload.ids[0]
const index = this.expenses.findIndex((e) => e.id === id)
if (index !== -1) {
this.expenses.splice(index, 1)
}
return { data: response }
},
async deleteMultipleExpenses(): Promise<{ data: { success: boolean } }> {
const response = await expenseService.delete({
ids: this.selectedExpenses,
})
this.selectedExpenses.forEach((expenseId) => {
const index = this.expenses.findIndex((e) => e.id === expenseId)
if (index !== -1) {
this.expenses.splice(index, 1)
}
})
this.selectedExpenses = []
return { data: response }
},
async fetchPaymentModes(
params?: Record<string, unknown>,
): Promise<{ data: { data: PaymentMethod[] } }> {
const { paymentService } = await import(
'../../../api/services/payment.service'
)
const response = await paymentService.listMethods(params as never)
this.paymentModes = response.data
return { data: response }
},
setSelectAllState(data: boolean): void {
this.selectAllField = data
},
selectExpense(data: number[]): void {
this.selectedExpenses = data
this.selectAllField =
this.selectedExpenses.length === this.expenses.length
},
selectAllExpenses(): void {
if (this.selectedExpenses.length === this.expenses.length) {
this.selectedExpenses = []
this.selectAllField = false
} else {
this.selectedExpenses = this.expenses.map((e) => e.id)
this.selectAllField = true
}
},
},
})
/**
* Convert an object to FormData, handling nested properties and files.
*/
function toFormData(obj: Record<string, unknown>): FormData {
const formData = new FormData()
for (const key of Object.keys(obj)) {
const value = obj[key]
if (value === null || value === undefined) {
continue
}
if (value instanceof File) {
formData.append(key, value)
} else if (typeof value === 'object' && !(value instanceof Blob)) {
formData.append(key, JSON.stringify(value))
} else {
formData.append(key, String(value))
}
}
return formData
}
export type ExpenseStore = ReturnType<typeof useExpenseStore>

View File

@@ -0,0 +1,368 @@
<template>
<BasePage class="relative">
<form action="" @submit.prevent="submitForm">
<BasePageHeader :title="pageTitle" class="mb-5">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$t('expenses.expense', 2)"
to="/admin/expenses"
/>
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-if="isEdit && expenseStore.currentExpense.attachment_receipt_url"
:href="receiptDownloadUrl"
tag="a"
variant="primary-outline"
type="button"
class="mr-2"
>
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
</template>
{{ $t('expenses.download_receipt') }}
</BaseButton>
<div class="hidden md:block">
<BaseButton
:loading="isSaving"
:content-loading="isFetchingInitialData"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('expenses.update_expense')
: $t('expenses.save_expense')
}}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseCard>
<BaseInputGrid>
<!-- Category -->
<BaseInputGroup
:label="$t('expenses.category')"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-if="!isFetchingInitialData"
v-model="expenseStore.currentExpense.expense_category_id"
:content-loading="isFetchingInitialData"
value-prop="id"
label="name"
track-by="id"
:options="searchCategory"
:filter-results="false"
resolve-on-load
:delay="500"
searchable
:placeholder="$t('expenses.categories.select_a_category')"
/>
</BaseInputGroup>
<!-- Expense Date -->
<BaseInputGroup
:label="$t('expenses.expense_date')"
:content-loading="isFetchingInitialData"
required
>
<BaseDatePicker
v-model="expenseStore.currentExpense.expense_date"
:content-loading="isFetchingInitialData"
:calendar-button="true"
/>
</BaseInputGroup>
<!-- Expense Number -->
<BaseInputGroup
:label="$t('expenses.expense_number')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="expenseStore.currentExpense.expense_number"
:content-loading="isFetchingInitialData"
type="text"
name="expense_number"
:placeholder="$t('expenses.expense_number_placeholder')"
/>
</BaseInputGroup>
<!-- Amount -->
<BaseInputGroup
:label="$t('expenses.amount')"
:content-loading="isFetchingInitialData"
required
>
<BaseMoney
:key="String(expenseStore.currentExpense.selectedCurrency)"
v-model="amountData"
class="focus:border focus:border-solid focus:border-primary-500"
:currency="expenseStore.currentExpense.selectedCurrency"
/>
</BaseInputGroup>
<!-- Currency -->
<BaseInputGroup
:label="$t('expenses.currency')"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="expenseStore.currentExpense.currency_id"
value-prop="id"
label="name"
track-by="name"
:content-loading="isFetchingInitialData"
:options="currencies"
searchable
:can-deselect="false"
:placeholder="$t('customers.select_currency')"
class="w-full"
@update:model-value="onCurrencyChange"
/>
</BaseInputGroup>
<!-- Customer -->
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('expenses.customer')"
>
<BaseMultiselect
v-if="!isFetchingInitialData"
v-model="expenseStore.currentExpense.customer_id"
:content-loading="isFetchingInitialData"
value-prop="id"
label="name"
track-by="id"
:options="searchCustomer"
:filter-results="false"
resolve-on-load
:delay="500"
searchable
:placeholder="$t('customers.select_a_customer')"
/>
</BaseInputGroup>
<!-- Payment Mode -->
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('payments.payment_mode')"
>
<BaseMultiselect
v-model="expenseStore.currentExpense.payment_method_id"
:content-loading="isFetchingInitialData"
label="name"
value-prop="id"
track-by="name"
:options="expenseStore.paymentModes"
:placeholder="$t('payments.select_payment_mode')"
searchable
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseInputGrid class="mt-4">
<!-- Notes -->
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('expenses.note')"
>
<BaseTextarea
v-model="expenseStore.currentExpense.notes"
:content-loading="isFetchingInitialData"
:row="4"
rows="4"
/>
</BaseInputGroup>
<!-- Receipt -->
<BaseInputGroup :label="$t('expenses.receipt')">
<BaseFileUploader
v-model="expenseStore.currentExpense.receiptFiles"
accept="image/*,.doc,.docx,.pdf,.csv,.xlsx,.xls"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
<!-- Mobile Save Button -->
<div class="block md:hidden">
<BaseButton
:loading="isSaving"
:tabindex="6"
variant="primary"
type="submit"
class="flex justify-center w-full"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('expenses.update_expense')
: $t('expenses.save_expense')
}}
</BaseButton>
</div>
</BaseInputGrid>
</BaseCard>
</form>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useExpenseStore } from '../store'
import type { ExpenseCategory } from '../../../../types/domain/expense'
import type { Customer } from '../../../../types/domain/customer'
import type { Currency } from '../../../../types/domain/currency'
interface Props {
currencies?: Currency[]
companyCurrency?: Currency | null
}
const props = withDefaults(defineProps<Props>(), {
currencies: () => [],
companyCurrency: null,
})
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const expenseStore = useExpenseStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const isAttachmentReceiptRemoved = ref<boolean>(false)
const amountData = computed<number>({
get: () => expenseStore.currentExpense.amount / 100,
set: (value: number) => {
expenseStore.currentExpense.amount = Math.round(value * 100)
},
})
const isEdit = computed<boolean>(() => route.name === 'expenses.edit')
const pageTitle = computed<string>(() =>
isEdit.value ? t('expenses.edit_expense') : t('expenses.new_expense'),
)
const receiptDownloadUrl = computed<string>(() =>
isEdit.value ? `/reports/expenses/${route.params.id}/download-receipt` : '',
)
// Initialize
expenseStore.resetCurrentExpenseData()
loadData()
function onFileInputChange(_fileName: string, file: File): void {
expenseStore.currentExpense.attachment_receipt = file
}
function onFileInputRemove(): void {
expenseStore.currentExpense.attachment_receipt = null
isAttachmentReceiptRemoved.value = true
}
function onCurrencyChange(currencyId: number): void {
const found = props.currencies.find((c) => c.id === currencyId)
expenseStore.currentExpense.selectedCurrency = found ?? null
}
async function searchCategory(
search: string,
): Promise<ExpenseCategory[]> {
const { expenseService } = await import(
'../../../../api/services/expense.service'
)
const res = await expenseService.listCategories({ search })
return res.data
}
async function searchCustomer(search: string): Promise<Customer[]> {
const { customerService } = await import(
'../../../../api/services/customer.service'
)
const res = await customerService.list({ search })
return res.data
}
async function loadData(): Promise<void> {
if (!isEdit.value && props.companyCurrency) {
expenseStore.currentExpense.currency_id = props.companyCurrency.id
expenseStore.currentExpense.selectedCurrency = props.companyCurrency
}
isFetchingInitialData.value = true
await expenseStore.fetchPaymentModes({ limit: 'all' })
if (isEdit.value) {
await expenseStore.fetchExpense(Number(route.params.id))
if (expenseStore.currentExpense.selectedCurrency) {
expenseStore.currentExpense.currency_id =
expenseStore.currentExpense.selectedCurrency.id
}
} else if (route.query.customer) {
expenseStore.currentExpense.customer_id = Number(route.query.customer)
}
isFetchingInitialData.value = false
}
async function submitForm(): Promise<void> {
isSaving.value = true
const formData: Record<string, unknown> = {
...expenseStore.currentExpense,
expense_number: expenseStore.currentExpense.expense_number || '',
}
try {
if (isEdit.value) {
await expenseStore.updateExpense({
id: Number(route.params.id),
data: formData,
isAttachmentReceiptRemoved: isAttachmentReceiptRemoved.value,
})
} else {
await expenseStore.addExpense(formData)
}
isSaving.value = false
expenseStore.currentExpense.attachment_receipt = null
isAttachmentReceiptRemoved.value = false
router.push('/admin/expenses')
} catch {
isSaving.value = false
}
}
onBeforeUnmount(() => {
expenseStore.resetCurrentExpenseData()
})
</script>

View File

@@ -0,0 +1,414 @@
<template>
<BasePage>
<BasePageHeader :title="$t('expenses.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('expenses.expense', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="expenseStore.totalExpenses"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="canCreate"
class="ml-4"
variant="primary"
@click="$router.push('expenses/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('expenses.add_expense') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Filters -->
<BaseFilterWrapper :show="showFilters" class="mt-5" @clear="clearFilter">
<BaseInputGroup :label="$t('expenses.customer')">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('expenses.category')">
<BaseMultiselect
v-model="filters.expense_category_id"
value-prop="id"
label="name"
track-by="name"
:filter-results="false"
resolve-on-load
:delay="500"
:options="searchCategory"
searchable
:placeholder="$t('expenses.categories.select_a_category')"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('expenses.from_date')">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('expenses.to_date')">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<!-- Empty State -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('expenses.no_expenses')"
:description="$t('expenses.list_of_expenses')"
>
<template v-if="canCreate" #actions>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/expenses/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('expenses.add_new_expense') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<!-- Table -->
<div v-show="!showEmptyScreen" class="relative table-container">
<div class="relative flex items-center justify-end h-5">
<BaseDropdown
v-if="expenseStore.selectedExpenses.length && canDelete"
>
<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="removeMultipleExpenses">
<BaseIcon name="TrashIcon" class="h-5 mr-3 text-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="expenseColumns"
class="mt-3"
>
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="selectAllFieldStatus"
variant="primary"
@change="expenseStore.selectAllExpenses"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-name="{ row }">
<router-link
:to="{ path: `expenses/${row.data.id}/edit` }"
class="font-medium text-primary-500"
>
{{ row.data.expense_category?.name ?? '-' }}
</router-link>
</template>
<template #cell-amount="{ row }">
<BaseFormatMoney
:amount="row.data.amount"
:currency="row.data.currency"
/>
</template>
<template #cell-expense_date="{ row }">
{{ row.data.formatted_expense_date }}
</template>
<template #cell-expense_number="{ row }">
{{ row.data.expense_number || '-' }}
</template>
<template #cell-user_name="{ row }">
<BaseText
:text="row.data.customer ? row.data.customer.name : '-'"
/>
</template>
<template #cell-notes="{ row }">
<div class="notes">
<div class="truncate note w-60">
{{ row.data.notes ? row.data.notes : '-' }}
</div>
</div>
</template>
<template v-if="hasAtLeastOneAbility" #cell-actions="{ row }">
<ExpenseDropdown
:row="row.data"
:table="tableRef"
:load-data="refreshTable"
:can-edit="canEdit"
:can-delete="canDelete"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, reactive, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useExpenseStore } from '../store'
import ExpenseDropdown from '../components/ExpenseDropdown.vue'
import type { Expense, ExpenseCategory } from '../../../../types/domain/expense'
interface Props {
canCreate?: boolean
canEdit?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canCreate: false,
canEdit: false,
canDelete: false,
})
const expenseStore = useExpenseStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const hasAtLeastOneAbility = computed<boolean>(() => {
return props.canDelete || props.canEdit
})
interface ExpenseFilters {
expense_category_id: string | number
from_date: string
to_date: string
customer_id: string | number
}
const filters = reactive<ExpenseFilters>({
expense_category_id: '',
from_date: '',
to_date: '',
customer_id: '',
})
const showEmptyScreen = computed<boolean>(
() => !expenseStore.totalExpenses && !isFetchingInitialData.value,
)
const selectField = computed<number[]>({
get: () => expenseStore.selectedExpenses,
set: (value: number[]) => {
expenseStore.selectExpense(value)
},
})
const selectAllFieldStatus = computed<boolean>({
get: () => expenseStore.selectAllField,
set: (value: boolean) => {
expenseStore.setSelectAllState(value)
},
})
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
placeholderClass?: string
sortable?: boolean
}
const expenseColumns = computed<TableColumn[]>(() => [
{
key: 'status',
thClass: 'extra w-10',
tdClass: 'font-medium text-heading',
placeholderClass: 'w-10',
sortable: false,
},
{
key: 'expense_date',
label: t('expenses.date'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'expense_number',
label: t('expenses.expense_number'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'name',
label: t('expenses.category'),
thClass: 'extra',
tdClass: 'cursor-pointer font-medium text-primary-500',
},
{ key: 'user_name', label: t('expenses.customer') },
{ key: 'notes', label: t('expenses.note') },
{ key: 'amount', label: t('expenses.amount') },
{
key: 'actions',
sortable: false,
tdClass: 'text-right text-sm font-medium',
},
])
debouncedWatch(filters, () => setFilters(), { debounce: 500 })
onUnmounted(() => {
if (expenseStore.selectAllField) {
expenseStore.selectAllExpenses()
}
})
async function searchCategory(search: string): Promise<ExpenseCategory[]> {
const response = await expenseService_listCategories({ search })
return response
}
/** Thin wrapper to fetch categories via expense service */
async function expenseService_listCategories(
params: Record<string, unknown>,
): Promise<ExpenseCategory[]> {
const { expenseService } = await import(
'../../../../api/services/expense.service'
)
const response = await expenseService.listCategories(params as never)
return response.data
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: Expense[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
...filters,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await expenseStore.fetchExpenses(data as never)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable(): void {
tableRef.value?.refresh()
}
function setFilters(): void {
refreshTable()
}
function clearFilter(): void {
filters.expense_category_id = ''
filters.from_date = ''
filters.to_date = ''
filters.customer_id = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function removeMultipleExpenses(): Promise<void> {
const confirmed = window.confirm(t('expenses.confirm_delete'))
if (!confirmed) return
const res = await expenseStore.deleteMultipleExpenses()
if (res.data) {
refreshTable()
}
}
</script>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { helpers, required, email } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useMemberStore } from '../store'
import { roleService } from '../../../../api/services/role.service'
import type { Role } from '../../../../types/domain/role'
interface Props {
show: boolean
}
interface Emits {
(e: 'close'): void
}
interface InviteForm {
email: string
role_id: number | null
}
withDefaults(defineProps<Props>(), {
show: false,
})
const emit = defineEmits<Emits>()
const memberStore = useMemberStore()
const { t } = useI18n()
const isSending = ref<boolean>(false)
const roles = ref<Role[]>([])
const form = reactive<InviteForm>({
email: '',
role_id: null,
})
const rules = computed(() => ({
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
role_id: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(
rules,
computed(() => form)
)
onMounted(async () => {
const response = await roleService.list()
roles.value = response.data as unknown as Role[]
})
async function submitInvitation(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSending.value = true
try {
await memberStore.inviteMember({
email: form.email,
role: form.role_id !== null ? String(form.role_id) : undefined,
})
form.email = ''
form.role_id = null
v$.value.$reset()
emit('close')
} catch {
// Error handled by store
} finally {
isSending.value = false
}
}
</script>
<template>
<BaseModal :show="show" @close="$emit('close')">
<template #header>
<div class="flex justify-between w-full">
{{ $t('members.invite_member') }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="$emit('close')"
/>
</div>
</template>
<form @submit.prevent="submitInvitation">
<div class="p-4 space-y-4">
<BaseInputGroup
:label="$t('members.email')"
:error="v$.email.$error && v$.email.$errors[0]?.$message"
required
>
<BaseInput
v-model="form.email"
type="email"
:invalid="v$.email.$error"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('members.role')"
:error="v$.role_id.$error && v$.role_id.$errors[0]?.$message"
required
>
<BaseMultiselect
v-model="form.role_id"
:options="roles"
label="title"
value-prop="id"
track-by="title"
:searchable="true"
/>
</BaseInputGroup>
</div>
<div class="flex justify-end p-4 border-t border-line-default">
<BaseButton
variant="primary-outline"
class="mr-3"
@click="$emit('close')"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSending"
:disabled="isSending"
type="submit"
>
{{ $t('members.invite_member') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useMemberStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
interface RowData {
id: number
[key: string]: unknown
}
interface Props {
row: RowData | null
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}
const props = withDefaults(defineProps<Props>(), {
row: null,
table: null,
loadData: null,
})
const memberStore = useMemberStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const route = useRoute()
function removeMember(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('members.confirm_delete', 1),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res: boolean) => {
if (res) {
memberStore.deleteUser({ users: [id] }).then((success) => {
if (success) {
props.loadData?.()
}
})
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'members.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Member -->
<router-link v-if="row" :to="`/admin/members/${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 Member -->
<BaseDropdownItem v-if="row" @click="removeMember(row.id)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,3 @@
export { useMemberStore } from './store'
export type { MemberForm } from './store'
export { default as memberRoutes } from './routes'

View File

@@ -0,0 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
const memberRoutes: RouteRecordRaw[] = [
{
path: 'members',
name: 'members.index',
component: () => import('./views/MemberIndexView.vue'),
meta: {
ability: 'view-member',
},
},
]
export default memberRoutes

View File

@@ -0,0 +1,284 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { memberService } from '../../../api/services/member.service'
import { roleService } from '../../../api/services/role.service'
import type {
MemberListParams,
MemberListResponse,
UpdateMemberPayload,
InviteMemberPayload,
DeleteMembersPayload,
} from '../../../api/services/member.service'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
import type { User } from '../../../types/domain/user'
import type { Role } from '../../../types/domain/role'
import type { CompanyInvitation } from '../../../types/domain/company'
export interface MemberForm {
id?: number
name: string
email: string
password: string | null
phone: string | null
role: string | null
companies: Array<{ id: number; role?: string }>
}
function createMemberStub(): MemberForm {
return {
name: '',
email: '',
password: null,
phone: null,
role: null,
companies: [],
}
}
export const useMemberStore = defineStore('members', () => {
// State
const users = ref<User[]>([])
const totalUsers = ref<number>(0)
const roles = ref<Role[]>([])
const pendingInvitations = ref<CompanyInvitation[]>([])
const currentMember = ref<MemberForm>(createMemberStub())
const selectAllField = ref<boolean>(false)
const selectedUsers = ref<number[]>([])
// Getters
const isEdit = computed<boolean>(() => !!currentMember.value.id)
// Actions
function resetCurrentMember(): void {
currentMember.value = createMemberStub()
}
async function fetchUsers(params?: MemberListParams): Promise<MemberListResponse> {
try {
const response = await memberService.list(params)
users.value = response.data
totalUsers.value = response.meta.total
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchUser(id: number): Promise<User> {
try {
const response = await memberService.get(id)
Object.assign(currentMember.value, response.data)
if (response.data.companies?.length) {
response.data.companies.forEach((c, i) => {
response.data.roles?.forEach((r) => {
if (r.scope === c.id) {
currentMember.value.companies[i] = {
...currentMember.value.companies[i],
role: r.name,
}
}
})
})
}
return response.data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchRoles(): Promise<Role[]> {
try {
const response = await roleService.list()
roles.value = response.data as unknown as Role[]
return roles.value
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function addUser(data: UpdateMemberPayload): Promise<User> {
try {
const response = await memberService.create(data)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'members.created_message',
})
return response.data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateUser(data: UpdateMemberPayload & { id: number }): Promise<User> {
try {
const response = await memberService.update(data.id, data)
if (response.data) {
const pos = users.value.findIndex((user) => user.id === response.data.id)
if (pos !== -1) {
users.value[pos] = response.data
}
}
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'members.updated_message',
})
return response.data
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteUser(payload: DeleteMembersPayload): Promise<boolean> {
try {
const response = await memberService.delete(payload)
payload.users.forEach((userId) => {
const index = users.value.findIndex((user) => user.id === userId)
if (index !== -1) {
users.value.splice(index, 1)
}
})
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'members.deleted_message',
})
return response.success
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function deleteMultipleUsers(): Promise<boolean> {
try {
const response = await memberService.delete({ users: selectedUsers.value })
selectedUsers.value.forEach((userId) => {
const index = users.value.findIndex((_user) => _user.id === userId)
if (index !== -1) {
users.value.splice(index, 1)
}
})
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'members.deleted_message',
})
return response.success
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchPendingInvitations(): Promise<CompanyInvitation[]> {
try {
const response = await memberService.fetchPendingInvitations()
pendingInvitations.value = response.invitations
return response.invitations
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function inviteMember(data: InviteMemberPayload): Promise<void> {
try {
await memberService.invite(data)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'members.invited_message',
})
await fetchPendingInvitations()
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function cancelInvitation(id: number): Promise<void> {
try {
await memberService.cancelInvitation(id)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'members.invitation_cancelled',
})
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.id !== id
)
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
function setSelectAllState(data: boolean): void {
selectAllField.value = data
}
function selectUser(data: number[]): void {
selectedUsers.value = data
selectAllField.value = selectedUsers.value.length === users.value.length
}
function selectAllUsers(): void {
if (selectedUsers.value.length === users.value.length) {
selectedUsers.value = []
selectAllField.value = false
} else {
selectedUsers.value = users.value.map((user) => user.id)
selectAllField.value = true
}
}
return {
users,
totalUsers,
roles,
pendingInvitations,
currentMember,
selectAllField,
selectedUsers,
isEdit,
resetCurrentMember,
fetchUsers,
fetchUser,
fetchRoles,
addUser,
updateUser,
deleteUser,
deleteMultipleUsers,
fetchPendingInvitations,
inviteMember,
cancelInvitation,
setSelectAllState,
selectUser,
selectAllUsers,
}
})

View File

@@ -0,0 +1,403 @@
<script setup lang="ts">
import { debouncedWatch } from '@vueuse/core'
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMemberStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { useUserStore } from '../../../../stores/user.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import MemberDropdown from '../components/MemberDropdown.vue'
import InviteMemberModal from '../components/InviteMemberModal.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 MemberFilters {
name: string
email: string
phone: string
}
const notificationStore = useNotificationStore()
const dialogStore = useDialogStore()
const memberStore = useMemberStore()
const userStore = useUserStore()
const tableComponent = ref<{ refresh: () => void } | null>(null)
const showFilters = ref<boolean>(false)
const showInviteModal = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
const { t } = useI18n()
const filters = reactive<MemberFilters>({
name: '',
email: '',
phone: '',
})
const userTableColumns = computed<TableColumn[]>(() => [
{
key: 'status',
thClass: 'extra',
tdClass: 'font-medium text-heading',
sortable: false,
},
{
key: 'name',
label: t('members.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ key: 'email', label: 'Email' },
{
key: 'role',
label: t('members.role'),
sortable: false,
},
{
key: 'phone',
label: t('members.phone'),
},
{
key: 'created_at',
label: t('members.added_on'),
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const showEmptyScreen = computed<boolean>(
() => !memberStore.totalUsers && !isFetchingInitialData.value
)
const selectField = computed<number[]>({
get: () => memberStore.selectedUsers,
set: (value: number[]) => {
memberStore.selectUser(value)
},
})
const selectAllFieldStatus = computed<boolean>({
get: () => memberStore.selectAllField,
set: (value: boolean) => {
memberStore.setSelectAllState(value)
},
})
debouncedWatch(
filters,
() => {
refreshTable()
},
{ debounce: 500 }
)
onMounted(() => {
memberStore.fetchUsers()
memberStore.fetchRoles()
memberStore.fetchPendingInvitations()
})
onUnmounted(() => {
if (memberStore.selectAllField) {
memberStore.selectAllUsers()
}
})
function refreshTable(): void {
tableComponent.value?.refresh()
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
display_name: filters.name,
phone: filters.phone,
email: filters.email,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await memberStore.fetchUsers(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.name = ''
filters.email = ''
filters.phone = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function cancelInvitation(id: number): void {
memberStore.cancelInvitation(id)
}
function removeMultipleUsers(): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('members.confirm_delete', 2),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res: boolean) => {
if (res) {
memberStore.deleteMultipleUsers().then((success) => {
if (success) {
refreshTable()
}
})
}
})
}
</script>
<template>
<BasePage>
<!-- Page Header Section -->
<BasePageHeader :title="$t('members.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('members.title', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton
v-show="memberStore.totalUsers"
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.currentUser?.is_owner"
@click="showInviteModal = true"
>
<template #left="slotProps">
<BaseIcon
name="EnvelopeIcon"
:class="slotProps.class"
aria-hidden="true"
/>
</template>
{{ $t('members.invite_member') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup :label="$t('members.name')" class="flex-1 mt-2 mr-4">
<BaseInput
v-model="filters.name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('members.email')" class="flex-1 mt-2 mr-4">
<BaseInput
v-model="filters.email"
type="text"
name="email"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup class="flex-1 mt-2" :label="$t('members.phone')">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<!-- Empty Placeholder -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('members.no_users')"
:description="$t('members.list_of_users')"
>
<AstronautIcon class="mt-5 mb-4" />
</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="memberStore.selectedUsers.length">
<template #activator>
<span
class="flex text-sm font-medium cursor-pointer select-none text-primary-400"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" class="h-5" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleUsers">
<BaseIcon name="TrashIcon" class="h-5 mr-3 text-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableComponent"
:data="fetchData"
:columns="userTableColumns"
class="mt-3"
>
<!-- 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="memberStore.selectAllUsers"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="custom-control custom-checkbox">
<BaseCheckbox
:id="row.data.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-name="{ row }">
<router-link
:to="{ path: `users/${row.data.id}/edit` }"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-role="{ row }">
<span>{{ row.data.roles?.length ? row.data.roles[0].title : '-' }}</span>
</template>
<template #cell-phone="{ row }">
<span>{{ row.data.phone ? row.data.phone : '-' }}</span>
</template>
<template #cell-created_at="{ row }">
<span>{{ row.data.formatted_created_at }}</span>
</template>
<template v-if="userStore.currentUser?.is_owner" #cell-actions="{ row }">
<MemberDropdown
:row="row.data"
:table="tableComponent"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
<!-- Pending Invitations Section -->
<div
v-if="userStore.currentUser?.is_owner && memberStore.pendingInvitations.length > 0"
class="mt-8"
>
<h3 class="text-lg font-medium text-heading mb-4">
{{ $t('members.pending_invitations') }}
</h3>
<BaseCard>
<div class="divide-y divide-line-default">
<div
v-for="invitation in memberStore.pendingInvitations"
:key="invitation.id"
class="flex items-center justify-between px-6 py-4"
>
<div>
<p class="text-sm font-medium text-heading">
{{ invitation.email }}
</p>
<p class="text-sm text-muted">
{{ invitation.role?.title }} &middot;
{{ $t('members.invited_by') }}: {{ invitation.invited_by?.name }}
</p>
</div>
<BaseButton
variant="danger"
size="sm"
@click="cancelInvitation(invitation.id)"
>
{{ $t('members.cancel_invitation') }}
</BaseButton>
</div>
</div>
</BaseCard>
</div>
<InviteMemberModal
:show="showInviteModal"
@close="showInviteModal = false"
/>
</BasePage>
</template>

View File

@@ -0,0 +1,83 @@
<template>
<div
class="relative shadow-md border-2 border-line-default/60 rounded-lg cursor-pointer overflow-hidden h-100"
@click="$router.push(`/admin/modules/${data.slug}`)"
>
<div
v-if="data.purchased"
class="absolute mt-5 px-6 w-full flex justify-end"
>
<label
v-if="data.purchased"
class="bg-white/75 text-xs px-3 py-1 font-semibold tracking-wide rounded"
>
{{ $t('modules.purchased') }}
</label>
<label
v-if="data.installed"
class="ml-2 bg-white/75 text-xs px-3 py-1 font-semibold tracking-wide rounded"
>
<span v-if="data.update_available">
{{ $t('modules.update_available') }}
</span>
<span v-else>
{{ $t('modules.installed') }}
</span>
</label>
</div>
<img
class="lg:h-64 md:h-48 w-full object-cover object-center"
:src="data.cover ?? ''"
alt="cover"
/>
<div class="px-6 py-5 flex flex-col bg-surface-secondary flex-1 justify-between">
<span class="text-lg sm:text-2xl font-medium whitespace-nowrap truncate text-primary-500">
{{ data.name }}
</span>
<div v-if="data.author_avatar" class="flex items-center mt-2">
<img
class="hidden h-10 w-10 rounded-full sm:inline-block mr-2"
:src="data.author_avatar"
alt=""
/>
<span>by</span>
<span class="ml-2 text-base font-semibold truncate">
{{ data.author_name }}
</span>
</div>
<base-text
:text="data.short_description ?? ''"
class="pt-4 text-muted h-16 line-clamp-2"
/>
<div class="flex justify-between mt-4 flex-col space-y-2 sm:space-y-0 sm:flex-row">
<div>
<BaseRating :rating="averageRating" />
</div>
<div class="text-xl md:text-2xl font-semibold whitespace-nowrap text-primary-500">
$
{{ data.monthly_price ? data.monthly_price / 100 : (data.yearly_price ?? 0) / 100 }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Module } from '../../../../types/domain/module'
interface Props {
data: Module
}
const props = defineProps<Props>()
const averageRating = computed<number>(() => {
return parseInt(String(props.data.average_rating ?? 0), 10)
})
</script>

View File

@@ -0,0 +1,17 @@
export { moduleRoutes } from './routes'
export { useModuleStore } from './store'
export type {
ModuleState,
ModuleStore,
ModuleDetailResponse,
ModuleDetailMeta,
InstallationStep,
} from './store'
// Views
export { default as ModuleIndexView } from './views/ModuleIndexView.vue'
export { default as ModuleDetailView } from './views/ModuleDetailView.vue'
// Components
export { default as ModuleCard } from './components/ModuleCard.vue'

View File

@@ -0,0 +1,25 @@
import type { RouteRecordRaw } from 'vue-router'
const ModuleIndexView = () => import('./views/ModuleIndexView.vue')
const ModuleDetailView = () => import('./views/ModuleDetailView.vue')
export const moduleRoutes: RouteRecordRaw[] = [
{
path: 'modules',
name: 'modules.index',
component: ModuleIndexView,
meta: {
ability: 'manage-module',
title: 'modules.title',
},
},
{
path: 'modules/:slug',
name: 'modules.view',
component: ModuleDetailView,
meta: {
ability: 'manage-module',
title: 'modules.title',
},
},
]

View File

@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import { moduleService } from '../../../api/services/module.service'
import type {
Module,
ModuleReview,
ModuleFaq,
ModuleLink,
ModuleScreenshot,
} from '../../../types/domain/module'
// ----------------------------------------------------------------
// Types
// ----------------------------------------------------------------
export interface ModuleDetailMeta {
modules: Module[]
}
export interface ModuleDetailResponse {
data: Module
meta: ModuleDetailMeta
}
export interface InstallationStep {
translationKey: string
stepUrl: string
time: string | null
started: boolean
completed: boolean
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface ModuleState {
currentModule: ModuleDetailResponse | null
modules: Module[]
apiToken: string | null
currentUser: {
api_token: string | null
}
enableModules: string[]
}
export const useModuleStore = defineStore('modules', {
state: (): ModuleState => ({
currentModule: null,
modules: [],
apiToken: null,
currentUser: {
api_token: null,
},
enableModules: [],
}),
getters: {
salesTaxUSEnabled: (state): boolean =>
state.enableModules.includes('SalesTaxUS'),
installedModules: (state): Module[] =>
state.modules.filter((m) => m.installed),
},
actions: {
async fetchModules(): Promise<void> {
const response = await moduleService.list()
this.modules = response.data
},
async fetchModule(slug: string): Promise<ModuleDetailResponse> {
const response = await moduleService.get(slug)
const data = response as unknown as ModuleDetailResponse
if ((data as Record<string, unknown>).error === 'invalid_token') {
this.currentModule = null
this.modules = []
this.apiToken = null
this.currentUser.api_token = null
return data
}
this.currentModule = data
return data
},
async checkApiToken(token: string): Promise<{ success: boolean; error?: string }> {
const response = await moduleService.checkToken(token)
return {
success: response.success ?? false,
error: response.error,
}
},
async disableModule(moduleName: string): Promise<{ success: boolean }> {
return moduleService.disable(moduleName)
},
async enableModule(moduleName: string): Promise<{ success: boolean }> {
return moduleService.enable(moduleName)
},
async installModule(
moduleName: string,
version: string,
onStepUpdate?: (step: InstallationStep) => void,
): Promise<boolean> {
const steps: InstallationStep[] = [
{
translationKey: 'modules.download_zip_file',
stepUrl: '/api/v1/modules/download',
time: null,
started: false,
completed: false,
},
{
translationKey: 'modules.unzipping_package',
stepUrl: '/api/v1/modules/unzip',
time: null,
started: false,
completed: false,
},
{
translationKey: 'modules.copying_files',
stepUrl: '/api/v1/modules/copy',
time: null,
started: false,
completed: false,
},
{
translationKey: 'modules.completing_installation',
stepUrl: '/api/v1/modules/complete',
time: null,
started: false,
completed: false,
},
]
let path: string | null = null
for (const step of steps) {
step.started = true
onStepUpdate?.(step)
try {
const stepFns: Record<string, () => Promise<Record<string, unknown>>> = {
'/api/v1/modules/download': () =>
moduleService.download({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
'/api/v1/modules/unzip': () =>
moduleService.unzip({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
'/api/v1/modules/copy': () =>
moduleService.copy({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
'/api/v1/modules/complete': () =>
moduleService.complete({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
}
const result = await stepFns[step.stepUrl]()
step.completed = true
onStepUpdate?.(step)
if ((result as Record<string, unknown>).path) {
path = (result as Record<string, unknown>).path as string
}
if (!(result as Record<string, unknown>).success) {
return false
}
} catch {
step.completed = true
onStepUpdate?.(step)
return false
}
}
return true
},
},
})
export type ModuleStore = ReturnType<typeof useModuleStore>

View File

@@ -0,0 +1,516 @@
<template>
<div v-if="isFetchingInitialData" class="p-8">
<div class="animate-pulse space-y-4">
<div class="h-8 bg-surface-tertiary rounded w-1/3" />
<div class="h-64 bg-surface-tertiary rounded" />
</div>
</div>
<BasePage v-else-if="moduleData" class="bg-surface">
<BasePageHeader :title="moduleData.name">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/modules" />
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<div class="lg:grid lg:grid-rows-1 lg:grid-cols-7 lg:gap-x-8 lg:gap-y-10 xl:gap-x-16 mt-6">
<!-- Image Gallery -->
<div class="lg:row-end-1 lg:col-span-4">
<div class="flex flex-col-reverse">
<!-- Thumbnails -->
<div class="hidden mt-6 w-full max-w-2xl mx-auto sm:block lg:max-w-none">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-6" role="tablist">
<button
v-if="thumbnail && videoUrl"
:class="[
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
{ 'outline-hidden ring-3 ring-offset-1 ring-primary-500': displayVideo },
]"
type="button"
@click="setDisplayVideo"
>
<span class="absolute inset-0 rounded-md overflow-hidden">
<img :src="thumbnail" alt="" class="w-full h-full object-center object-cover" />
</span>
</button>
<button
v-for="(screenshot, ssIdx) in displayImages"
:key="ssIdx"
:class="[
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
{ 'outline-hidden ring-3 ring-offset-1 ring-primary-500': displayImage === screenshot.url },
]"
type="button"
@click="setDisplayImage(screenshot.url)"
>
<span class="absolute inset-0 rounded-md overflow-hidden">
<img :src="screenshot.url" alt="" class="w-full h-full object-center object-cover" />
</span>
</button>
</div>
</div>
<!-- Video -->
<div v-if="displayVideo" class="aspect-w-4 aspect-h-3">
<iframe
:src="videoUrl ?? ''"
class="sm:rounded-lg"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</div>
<!-- Main Image -->
<div
v-else
class="aspect-w-4 aspect-h-3 rounded-lg bg-surface-tertiary overflow-hidden"
>
<img
:src="displayImage ?? ''"
alt="Module Images"
class="w-full h-full object-center object-cover sm:rounded-lg"
/>
</div>
</div>
</div>
<!-- Details -->
<div class="max-w-2xl mx-auto mt-10 lg:max-w-none lg:mt-0 lg:row-end-2 lg:row-span-2 lg:col-span-3 w-full">
<!-- Rating -->
<div class="flex items-center">
<BaseRating :rating="averageRating" />
</div>
<!-- Name & Version -->
<div class="flex flex-col-reverse">
<div class="mt-4">
<h1 class="text-2xl font-extrabold tracking-tight text-heading sm:text-3xl">
{{ moduleData.name }}
</h1>
<p v-if="moduleData.latest_module_version" class="text-sm text-muted mt-2">
{{ $t('modules.version') }}
{{ moduleVersion }} ({{ $t('modules.last_updated') }}
{{ updatedAt }})
</p>
</div>
</div>
<!-- Description -->
<div
class="prose prose-sm max-w-none text-muted text-sm my-10"
v-html="moduleData.long_description"
/>
<!-- Action Buttons -->
<div v-if="!moduleData.purchased">
<a
:href="buyLink"
target="_blank"
class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
>
<BaseButton size="xl" class="items-center flex justify-center text-base mt-10">
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
{{ $t('modules.buy_now') }}
</BaseButton>
</a>
</div>
<div v-else>
<!-- Not installed yet -->
<div v-if="!moduleData.installed" class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<BaseButton
v-if="moduleData.latest_module_version"
size="xl"
variant="primary-outline"
:loading="isInstalling"
:disabled="isInstalling"
class="mr-4 flex items-center justify-center text-base"
@click="handleInstall"
>
<BaseIcon v-if="!isInstalling" name="ArrowDownTrayIcon" class="mr-2" />
{{ $t('modules.install') }}
</BaseButton>
</div>
<!-- Already installed -->
<div v-else-if="isModuleInstalled" class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<BaseButton
v-if="moduleData.update_available"
variant="primary"
size="xl"
:loading="isInstalling"
:disabled="isInstalling"
class="mr-4 flex items-center justify-center text-base"
@click="handleInstall"
>
{{ $t('modules.update_to') }}
<span class="ml-2">{{ moduleData.latest_module_version }}</span>
</BaseButton>
<BaseButton
v-if="moduleData.enabled"
variant="danger"
size="xl"
:loading="isDisabling"
:disabled="isDisabling"
class="mr-4 flex items-center justify-center text-base"
@click="handleDisable"
>
<BaseIcon v-if="!isDisabling" name="NoSymbolIcon" class="mr-2" />
{{ $t('modules.disable') }}
</BaseButton>
<BaseButton
v-else
variant="primary-outline"
size="xl"
:loading="isEnabling"
:disabled="isEnabling"
class="mr-4 flex items-center justify-center text-base"
@click="handleEnable"
>
<BaseIcon v-if="!isEnabling" name="CheckIcon" class="mr-2" />
{{ $t('modules.enable') }}
</BaseButton>
</div>
</div>
<!-- Highlights -->
<div v-if="moduleData.highlights" class="border-t border-line-default mt-10 pt-10">
<h3 class="text-sm font-medium text-heading">
{{ $t('modules.what_you_get') }}
</h3>
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="moduleData.highlights" />
</div>
<!-- Links -->
<div v-if="moduleData.links?.length" class="border-t border-line-default mt-10 pt-10">
<div
v-for="(link, key) in moduleData.links"
:key="key"
class="mb-4 last:mb-0 flex"
>
<BaseIcon :name="(link as ModuleLinkItem).icon ?? ''" class="mr-4" />
<a :href="(link as ModuleLinkItem).link" class="text-primary-500" target="_blank">
{{ (link as ModuleLinkItem).label }}
</a>
</div>
</div>
<!-- Installation Steps -->
<div v-if="isInstalling" class="border-t border-line-default mt-10 pt-10">
<ul class="w-full p-0 list-none">
<li
v-for="step in installationSteps"
:key="step.translationKey"
class="flex justify-between w-full py-3 border-b border-line-default border-solid last:border-b-0"
>
<p class="m-0 text-sm leading-8">{{ $t(step.translationKey) }}</p>
<span
:class="stepStatusClass(step)"
class="block py-1 text-sm text-center uppercase rounded-full"
style="width: 88px"
>
{{ stepStatusLabel(step) }}
</span>
</li>
</ul>
</div>
</div>
<!-- Tabs: Reviews, FAQ, License -->
<div class="w-full max-w-2xl mx-auto mt-16 lg:max-w-none lg:mt-0 lg:col-span-4">
<!-- Simple tab implementation -->
<div class="-mb-px flex space-x-8 border-b border-line-default">
<button
v-for="tab in tabs"
:key="tab.key"
:class="[
activeTab === tab.key
? 'border-primary-600 text-primary-600'
: 'border-transparent text-body hover:text-heading hover:border-line-strong',
'whitespace-nowrap py-6 border-b-2 font-medium text-sm',
]"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Reviews -->
<div v-if="activeTab === 'reviews'" class="-mb-10">
<div v-if="moduleData.reviews?.length">
<div
v-for="(review, reviewIdx) in moduleData.reviews"
:key="reviewIdx"
class="flex text-sm text-muted space-x-4"
>
<div class="flex-none py-10">
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full bg-surface-secondary">
<span class="text-lg font-medium leading-none text-white uppercase">
{{ review.user?.[0] ?? '?' }}
</span>
</span>
</div>
<div :class="[reviewIdx === 0 ? '' : 'border-t border-line-default', 'py-10']">
<h3 class="font-medium text-heading">{{ review.user }}</h3>
<p>{{ formatDate(review.created_at) }}</p>
<div class="flex items-center mt-4">
<BaseRating :rating="review.rating" />
</div>
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="review.comment" />
</div>
</div>
</div>
<div v-else class="flex w-full items-center justify-center">
<p class="text-muted mt-10 text-sm">{{ $t('modules.no_reviews_found') }}</p>
</div>
</div>
<!-- FAQ -->
<dl v-if="activeTab === 'faq'" class="text-sm text-muted">
<template v-for="faq in moduleData.faq" :key="faq.question">
<dt class="mt-10 font-medium text-heading">{{ faq.question }}</dt>
<dd class="mt-2 prose prose-sm max-w-none text-muted">
<p>{{ faq.answer }}</p>
</dd>
</template>
</dl>
<!-- License -->
<div v-if="activeTab === 'license'" class="pt-10">
<div class="prose prose-sm max-w-none text-muted" v-html="moduleData.license" />
</div>
</div>
</div>
<!-- Other Modules -->
<div v-if="otherModules?.length" class="mt-24 sm:mt-32 lg:max-w-none">
<div class="flex items-center justify-between space-x-4">
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
<a
href="/admin/modules"
class="whitespace-nowrap text-sm font-medium text-primary-600 hover:text-primary-500"
>
{{ $t('modules.view_all') }}
<span aria-hidden="true"> &rarr;</span>
</a>
</div>
<div class="mt-6 grid grid-cols-1 gap-x-8 gap-y-8 sm:grid-cols-2 sm:gap-y-10 lg:grid-cols-4">
<div v-for="(other, moduleIdx) in otherModules" :key="moduleIdx">
<ModuleCard :data="other" />
</div>
</div>
</div>
<div class="p-6" />
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useModuleStore } from '../store'
import type { InstallationStep } from '../store'
import ModuleCard from '../components/ModuleCard.vue'
import type { Module, ModuleLink } from '../../../../types/domain/module'
interface ModuleLinkItem {
icon: string
link: string
label: string
}
interface TabItem {
key: string
label: string
}
const moduleStore = useModuleStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const isFetchingInitialData = ref<boolean>(true)
const isInstalling = ref<boolean>(false)
const isEnabling = ref<boolean>(false)
const isDisabling = ref<boolean>(false)
const displayImage = ref<string | null>('')
const displayVideo = ref<boolean>(false)
const thumbnail = ref<string | null>(null)
const videoUrl = ref<string | null>(null)
const activeTab = ref<string>('reviews')
const installationSteps = reactive<InstallationStep[]>([])
const tabs = computed<TabItem[]>(() => [
{ key: 'reviews', label: t('modules.customer_reviews') },
{ key: 'faq', label: t('modules.faq') },
{ key: 'license', label: t('modules.license') },
])
const moduleData = computed<Module | undefined>(() => {
return moduleStore.currentModule?.data
})
const otherModules = computed<Module[] | undefined>(() => {
return moduleStore.currentModule?.meta?.modules
})
const averageRating = computed<number>(() => {
return parseInt(String(moduleData.value?.average_rating ?? 0), 10)
})
const isModuleInstalled = computed<boolean>(() => {
return !!(moduleData.value?.installed && moduleData.value?.latest_module_version)
})
const moduleVersion = computed<string>(() => {
return moduleData.value?.installed_module_version ?? moduleData.value?.latest_module_version ?? ''
})
const updatedAt = computed<string>(() => {
const date =
moduleData.value?.installed_module_version_updated_at ??
moduleData.value?.latest_module_version_updated_at
return date ? formatDate(date) : ''
})
const displayImages = computed<Array<{ url: string }>>(() => {
const images: Array<{ url: string }> = []
if (moduleData.value?.cover) {
images.push({ url: moduleData.value.cover })
}
if (moduleData.value?.screenshots) {
moduleData.value.screenshots.forEach((s) => images.push({ url: s.url }))
}
return images
})
const buyLink = computed<string>(() => {
return `/modules/${moduleData.value?.slug ?? ''}`
})
watch(() => route.params.slug, () => {
loadData()
})
onMounted(() => {
loadData()
})
async function loadData(): Promise<void> {
const slug = route.params.slug as string
if (!slug) return
isFetchingInitialData.value = true
await moduleStore.fetchModule(slug)
videoUrl.value = moduleData.value?.video_link ?? null
thumbnail.value = moduleData.value?.video_thumbnail ?? null
if (videoUrl.value) {
setDisplayVideo()
} else {
displayImage.value = moduleData.value?.cover ?? null
}
isFetchingInitialData.value = false
}
async function handleInstall(): Promise<void> {
if (!moduleData.value) return
installationSteps.length = 0
isInstalling.value = true
const success = await moduleStore.installModule(
moduleData.value.module_name,
moduleData.value.latest_module_version,
(step) => {
const existing = installationSteps.find(
(s) => s.translationKey === step.translationKey,
)
if (existing) {
Object.assign(existing, step)
} else {
installationSteps.push({ ...step })
}
},
)
isInstalling.value = false
if (success) {
setTimeout(() => location.reload(), 1500)
}
}
async function handleDisable(): Promise<void> {
if (!moduleData.value) return
const confirmed = window.confirm(t('modules.disable_warning'))
if (!confirmed) return
isDisabling.value = true
const res = await moduleStore.disableModule(moduleData.value.module_name)
isDisabling.value = false
if (res.success) {
setTimeout(() => location.reload(), 1500)
}
}
async function handleEnable(): Promise<void> {
if (!moduleData.value) return
isEnabling.value = true
const res = await moduleStore.enableModule(moduleData.value.module_name)
isEnabling.value = false
if (res.success) {
setTimeout(() => location.reload(), 1500)
}
}
function setDisplayImage(url: string): void {
displayVideo.value = false
displayImage.value = url
}
function setDisplayVideo(): void {
displayVideo.value = true
displayImage.value = null
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function stepStatusClass(step: InstallationStep): string {
const status = stepStatusLabel(step)
switch (status) {
case 'pending':
return 'text-primary-800 bg-surface-muted'
case 'finished':
return 'text-teal-500 bg-teal-100'
case 'running':
return 'text-blue-400 bg-blue-100'
default:
return 'text-danger bg-red-200'
}
}
function stepStatusLabel(step: InstallationStep): string {
if (step.started && step.completed) return 'finished'
if (step.started && !step.completed) return 'running'
return 'pending'
}
</script>

View File

@@ -0,0 +1,177 @@
<template>
<BasePage>
<BasePageHeader :title="$t('modules.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('modules.module', 2)" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<!-- Connected: module listing -->
<div v-if="hasApiToken && moduleStore.modules">
<BaseTabGroup class="-mb-5" @change="setStatusFilter">
<BaseTab :title="$t('general.all')" filter="" />
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
</BaseTabGroup>
<!-- Placeholder -->
<div
v-if="isFetchingModule"
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
>
<div v-for="n in 3" :key="n" class="h-80 bg-surface-tertiary rounded-lg animate-pulse" />
</div>
<!-- Module Cards -->
<div v-else>
<div
v-if="filteredModules.length"
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
>
<div v-for="(mod, idx) in filteredModules" :key="idx">
<ModuleCard :data="mod" />
</div>
</div>
<div v-else class="mt-24">
<label class="flex items-center justify-center text-muted">
{{ $t('modules.no_modules_installed') }}
</label>
</div>
</div>
</div>
<!-- Not connected: API token form -->
<BaseCard v-else class="mt-6">
<h6 class="text-heading text-lg font-medium">
{{ $t('modules.connect_installation') }}
</h6>
<p class="mt-1 text-sm text-muted">
{{ $t('modules.api_token_description', { url: baseUrlDisplay }) }}
</p>
<div class="grid lg:grid-cols-2 mt-6">
<form class="mt-6" @submit.prevent="submitApiToken">
<BaseInputGroup
:label="$t('modules.api_token')"
required
:error="v$.api_token.$error ? String(v$.api_token.$errors[0]?.$message) : undefined"
>
<BaseInput
v-model="moduleStore.currentUser.api_token"
:invalid="v$.api_token.$error"
@input="v$.api_token.$touch()"
/>
</BaseInputGroup>
<div class="flex space-x-2">
<BaseButton class="mt-6" :loading="isSaving" type="submit">
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
<a
:href="signUpUrl"
class="mt-6 block"
target="_blank"
>
<BaseButton variant="primary-outline" type="button">
{{ $t('modules.sign_up_and_get_token') }}
</BaseButton>
</a>
</div>
</form>
</div>
</BaseCard>
</BasePage>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useModuleStore } from '../store'
import ModuleCard from '../components/ModuleCard.vue'
import type { Module } from '../../../../types/domain/module'
interface Props {
baseUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '',
})
const moduleStore = useModuleStore()
const { t } = useI18n()
const activeTab = ref<string>('')
const isSaving = ref<boolean>(false)
const isFetchingModule = ref<boolean>(false)
const rules = computed(() => ({
api_token: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3),
),
},
}))
const v$ = useVuelidate(
rules,
computed(() => moduleStore.currentUser),
)
const hasApiToken = computed<boolean>(() => !!moduleStore.apiToken)
const filteredModules = computed<Module[]>(() => {
if (activeTab.value === 'INSTALLED') {
return moduleStore.installedModules
}
return moduleStore.modules
})
const baseUrlDisplay = computed<string>(() => {
return props.baseUrl.replace(/^http:\/\//, '')
})
const signUpUrl = computed<string>(() => {
return `${props.baseUrl}/auth/customer/register`
})
watch(hasApiToken, (val) => {
if (val) fetchModulesData()
}, { immediate: true })
async function fetchModulesData(): Promise<void> {
isFetchingModule.value = true
await moduleStore.fetchModules()
isFetchingModule.value = false
}
async function submitApiToken(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
const response = await moduleStore.checkApiToken(
moduleStore.currentUser.api_token ?? '',
)
if (response.success) {
moduleStore.apiToken = moduleStore.currentUser.api_token
}
} finally {
isSaving.value = false
}
}
function setStatusFilter(data: { filter: string }): void {
activeTab.value = data.filter
}
</script>

View File

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

View File

@@ -0,0 +1,147 @@
<template>
<BaseDropdown :content-loading="contentLoading">
<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>
<!-- Copy PDF url -->
<BaseDropdownItem
v-if="isDetailView && canView"
class="rounded-md"
@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 Payment -->
<router-link
v-if="canEdit"
:to="`/admin/payments/${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 Payment -->
<router-link
v-if="!isDetailView && canView"
:to="`/admin/payments/${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 Payment -->
<BaseDropdownItem
v-if="!isDetailView && canSend"
@click="sendPayment"
>
<BaseIcon
name="PaperAirplaneIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('payments.send_payment') }}
</BaseDropdownItem>
<!-- Delete Payment -->
<BaseDropdownItem v-if="canDelete" @click="removePayment">
<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 { usePaymentStore } from '../store'
import type { Payment } from '../../../../types/domain/payment'
interface TableRef {
refresh: () => void
}
interface Props {
row: Payment | Record<string, unknown>
table?: TableRef | null
contentLoading?: boolean
canEdit?: boolean
canView?: boolean
canDelete?: boolean
canSend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
table: null,
contentLoading: false,
canEdit: false,
canView: false,
canDelete: false,
canSend: false,
})
const paymentStore = usePaymentStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isDetailView = computed<boolean>(() => route.name === 'payments.view')
async function removePayment(): Promise<void> {
const confirmed = window.confirm(t('payments.confirm_delete'))
if (!confirmed) return
const payment = props.row as Payment
await paymentStore.deletePayment({ ids: [payment.id] })
router.push('/admin/payments')
props.table?.refresh()
}
function copyPdfUrl(): void {
const payment = props.row as Payment
const pdfUrl = `${window.location.origin}/payments/pdf/${payment.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)
})
}
function sendPayment(): void {
const payment = props.row as Payment
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('payments.send_payment'),
componentName: 'SendPaymentModal',
id: payment.id,
data: payment,
variant: 'lg',
})
}
</script>

View File

@@ -0,0 +1,12 @@
export { usePaymentStore } from './store'
export type { PaymentStore, PaymentFormData, PaymentState } from './store'
export { paymentRoutes } from './routes'
// Views
export { default as PaymentIndexView } from './views/PaymentIndexView.vue'
export { default as PaymentCreateView } from './views/PaymentCreateView.vue'
export { default as PaymentDetailView } from './views/PaymentDetailView.vue'
// Components
export { default as PaymentDropdown } from './components/PaymentDropdown.vue'
export { default as PaidStatusBadge } from './components/PaidStatusBadge.vue'

View File

@@ -0,0 +1,53 @@
import type { RouteRecordRaw } from 'vue-router'
const PaymentIndexView = () => import('./views/PaymentIndexView.vue')
const PaymentCreateView = () => import('./views/PaymentCreateView.vue')
const PaymentDetailView = () => import('./views/PaymentDetailView.vue')
export const paymentRoutes: RouteRecordRaw[] = [
{
path: 'payments',
name: 'payments.index',
component: PaymentIndexView,
meta: {
ability: 'view-payment',
title: 'payments.title',
},
},
{
path: 'payments/create',
name: 'payments.create',
component: PaymentCreateView,
meta: {
ability: 'create-payment',
title: 'payments.new_payment',
},
},
{
path: 'payments/:id/edit',
name: 'payments.edit',
component: PaymentCreateView,
meta: {
ability: 'edit-payment',
title: 'payments.edit_payment',
},
},
{
path: 'payments/:id/view',
name: 'payments.view',
component: PaymentDetailView,
meta: {
ability: 'view-payment',
title: 'payments.title',
},
},
{
path: 'payments/:id/create',
name: 'payments.create-from-invoice',
component: PaymentCreateView,
meta: {
ability: 'create-payment',
title: 'payments.new_payment',
},
},
]

View File

@@ -0,0 +1,316 @@
import { defineStore } from 'pinia'
import { paymentService } from '../../../api/services/payment.service'
import type {
PaymentListParams,
PaymentListResponse,
SendPaymentPayload,
} from '../../../api/services/payment.service'
import type {
Payment,
PaymentMethod,
CreatePaymentPayload,
} from '../../../types/domain/payment'
import type { Customer } from '../../../types/domain/customer'
import type { Currency } from '../../../types/domain/currency'
import type { Note } from '../../../types/domain/note'
import type { CustomFieldValue } from '../../../types/domain/custom-field'
// ----------------------------------------------------------------
// Stub factories
// ----------------------------------------------------------------
export interface PaymentFormData {
id: number | null
payment_number: string
payment_date: string
customer_id: number | null
customer: Customer | null
selectedCustomer: Customer | null
invoice_id: number | null
amount: number
payment_method_id: number | null
notes: string | null
currency: Currency | Record<string, unknown> | null
currency_id: number | null
exchange_rate: number | null
maxPayableAmount: number
selectedNote: Note | null
customFields: CustomFieldValue[]
fields: CustomFieldValue[]
unique_hash?: string
}
function createPaymentStub(): PaymentFormData {
return {
id: null,
payment_number: '',
payment_date: '',
customer_id: null,
customer: null,
selectedCustomer: null,
invoice_id: null,
amount: 0,
payment_method_id: null,
notes: '',
currency: null,
currency_id: null,
exchange_rate: null,
maxPayableAmount: Number.MAX_SAFE_INTEGER,
selectedNote: null,
customFields: [],
fields: [],
}
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface PaymentState {
payments: Payment[]
paymentTotalCount: number
selectAllField: boolean
selectedPayments: number[]
selectedNote: Note | null
showExchangeRate: boolean
paymentModes: PaymentMethod[]
currentPaymentMode: { id: number | string; name: string | null }
currentPayment: PaymentFormData
isFetchingInitialData: boolean
}
export const usePaymentStore = defineStore('payment', {
state: (): PaymentState => ({
payments: [],
paymentTotalCount: 0,
selectAllField: false,
selectedPayments: [],
selectedNote: null,
showExchangeRate: false,
paymentModes: [],
currentPaymentMode: { id: '', name: null },
currentPayment: createPaymentStub(),
isFetchingInitialData: false,
}),
getters: {
getPayment:
(state) =>
(id: number): Payment | undefined => {
return state.payments.find((p) => p.id === id)
},
},
actions: {
resetCurrentPayment(): void {
this.currentPayment = createPaymentStub()
},
async fetchPayments(
params: PaymentListParams & {
payment_method_id?: number | string
payment_number?: string
},
): Promise<{ data: PaymentListResponse }> {
const response = await paymentService.list(params)
this.payments = response.data
this.paymentTotalCount = response.meta.payment_total_count
return { data: response }
},
async fetchPayment(id: number): Promise<{ data: { data: Payment } }> {
const response = await paymentService.get(id)
Object.assign(this.currentPayment, response.data)
return { data: response }
},
async addPayment(
data: Record<string, unknown>,
): Promise<{ data: { data: Payment } }> {
const response = await paymentService.create(data as never)
this.payments.push(response.data)
return { data: response }
},
async updatePayment(
data: Record<string, unknown>,
): Promise<{ data: { data: Payment } }> {
const response = await paymentService.update(
data.id as number,
data as never,
)
const pos = this.payments.findIndex((p) => p.id === response.data.id)
if (pos !== -1) {
this.payments[pos] = response.data
}
return { data: response }
},
async deletePayment(
payload: { ids: number[] },
): Promise<{ data: { success: boolean } }> {
const response = await paymentService.delete(payload)
const id = payload.ids[0]
const index = this.payments.findIndex((p) => p.id === id)
if (index !== -1) {
this.payments.splice(index, 1)
}
return { data: response }
},
async deleteMultiplePayments(): Promise<{ data: { success: boolean } }> {
const response = await paymentService.delete({
ids: this.selectedPayments,
})
this.selectedPayments.forEach((paymentId) => {
const index = this.payments.findIndex((p) => p.id === paymentId)
if (index !== -1) {
this.payments.splice(index, 1)
}
})
this.selectedPayments = []
return { data: response }
},
async sendEmail(data: SendPaymentPayload): Promise<unknown> {
return paymentService.send(data)
},
async previewPayment(id: number): Promise<unknown> {
return paymentService.sendPreview(id)
},
async getNextNumber(
params?: Record<string, unknown>,
setState = false,
): Promise<{ data: { nextNumber: string } }> {
const response = await paymentService.getNextNumber(params as never)
if (setState) {
this.currentPayment.payment_number = response.nextNumber
}
return { data: response }
},
async fetchPaymentModes(
params?: Record<string, unknown>,
): Promise<{ data: { data: PaymentMethod[] } }> {
const response = await paymentService.listMethods(params as never)
this.paymentModes = response.data
return { data: response }
},
async fetchPaymentMode(id: number): Promise<{ data: { data: PaymentMethod } }> {
const response = await paymentService.getMethod(id)
this.currentPaymentMode = response.data
return { data: response }
},
async addPaymentMode(
data: { name: string },
): Promise<{ data: { data: PaymentMethod } }> {
const response = await paymentService.createMethod(data)
this.paymentModes.push(response.data)
return { data: response }
},
async updatePaymentMode(
data: { id: number; name: string },
): Promise<{ data: { data: PaymentMethod } }> {
const response = await paymentService.updateMethod(data.id, data)
const pos = this.paymentModes.findIndex((m) => m.id === response.data.id)
if (pos !== -1) {
this.paymentModes[pos] = response.data
}
return { data: response }
},
async deletePaymentMode(id: number): Promise<{ data: { success: boolean } }> {
const response = await paymentService.deleteMethod(id)
const index = this.paymentModes.findIndex((m) => m.id === id)
if (index !== -1) {
this.paymentModes.splice(index, 1)
}
return { data: response }
},
selectPayment(data: number[]): void {
this.selectedPayments = data
this.selectAllField =
this.selectedPayments.length === this.payments.length
},
selectAllPayments(): void {
if (this.selectedPayments.length === this.payments.length) {
this.selectedPayments = []
this.selectAllField = false
} else {
this.selectedPayments = this.payments.map((p) => p.id)
this.selectAllField = true
}
},
setSelectAllState(data: boolean): void {
this.selectAllField = data
},
selectNote(data: Note): void {
this.selectedNote = null
this.selectedNote = data
},
resetSelectedNote(): void {
this.selectedNote = null
},
async fetchPaymentInitialData(
isEdit: boolean,
routeParams?: { id?: string },
companyCurrency?: Currency,
): Promise<void> {
this.isFetchingInitialData = true
const editActions: Promise<unknown>[] = []
if (isEdit && routeParams?.id) {
editActions.push(this.fetchPayment(Number(routeParams.id)))
}
try {
const [, nextNumRes, editRes] = await Promise.all([
this.fetchPaymentModes({ limit: 'all' }),
this.getNextNumber(),
...editActions,
])
if (isEdit) {
const paymentRes = editRes as { data: { data: Payment } } | undefined
if (paymentRes?.data?.data?.invoice) {
this.currentPayment.maxPayableAmount = parseInt(
String(paymentRes.data.data.invoice.due_amount),
)
}
} else if (!isEdit && nextNumRes) {
const now = new Date()
this.currentPayment.payment_date = formatDate(now)
this.currentPayment.payment_number =
nextNumRes.data.nextNumber
if (companyCurrency) {
this.currentPayment.currency = companyCurrency
}
}
} catch {
// Error handling
} finally {
this.isFetchingInitialData = false
}
},
},
})
function formatDate(date: Date): 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 PaymentStore = ReturnType<typeof usePaymentStore>

View File

@@ -0,0 +1,282 @@
<template>
<BasePage class="relative payment-create">
<form action="" @submit.prevent="submitPaymentData">
<BasePageHeader :title="pageTitle" class="mb-5">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
to="/admin/dashboard"
/>
<BaseBreadcrumbItem
:title="$t('payments.payment', 2)"
to="/admin/payments"
/>
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
class="hidden sm:flex"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('payments.update_payment')
: $t('payments.save_payment')
}}
</BaseButton>
</template>
</BasePageHeader>
<BaseCard>
<BaseInputGrid>
<!-- Payment Date -->
<BaseInputGroup
:label="$t('payments.date')"
:content-loading="isLoadingContent"
required
>
<BaseDatePicker
v-model="paymentStore.currentPayment.payment_date"
:content-loading="isLoadingContent"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<!-- Payment Number -->
<BaseInputGroup
:label="$t('payments.payment_number')"
:content-loading="isLoadingContent"
required
>
<BaseInput
v-model="paymentStore.currentPayment.payment_number"
:content-loading="isLoadingContent"
/>
</BaseInputGroup>
<!-- Customer -->
<BaseInputGroup
:label="$t('payments.customer')"
:content-loading="isLoadingContent"
required
>
<BaseCustomerSelectInput
v-if="!isLoadingContent"
v-model="paymentStore.currentPayment.customer_id"
:content-loading="isLoadingContent"
:placeholder="$t('customers.select_a_customer')"
show-action
@update:model-value="
selectNewCustomer(paymentStore.currentPayment.customer_id)
"
/>
</BaseInputGroup>
<!-- Invoice -->
<BaseInputGroup
:content-loading="isLoadingContent"
:label="$t('payments.invoice')"
>
<BaseMultiselect
v-model="paymentStore.currentPayment.invoice_id"
:content-loading="isLoadingContent"
value-prop="id"
track-by="invoice_number"
label="invoice_number"
:options="invoiceList"
:loading="isLoadingInvoices"
:placeholder="$t('invoices.select_invoice')"
@select="onSelectInvoice"
/>
</BaseInputGroup>
<!-- Amount -->
<BaseInputGroup
:label="$t('payments.amount')"
:content-loading="isLoadingContent"
required
>
<div class="relative w-full">
<BaseMoney
:key="String(paymentStore.currentPayment.currency)"
v-model="amount"
:currency="paymentStore.currentPayment.currency"
:content-loading="isLoadingContent"
/>
</div>
</BaseInputGroup>
<!-- Payment Mode -->
<BaseInputGroup
:content-loading="isLoadingContent"
:label="$t('payments.payment_mode')"
>
<BaseMultiselect
v-model="paymentStore.currentPayment.payment_method_id"
:content-loading="isLoadingContent"
label="name"
value-prop="id"
track-by="name"
:options="paymentStore.paymentModes"
:placeholder="$t('payments.select_payment_mode')"
searchable
/>
</BaseInputGroup>
</BaseInputGrid>
<!-- Notes -->
<div class="relative mt-6">
<label class="mb-4 text-sm font-medium text-heading">
{{ $t('estimates.notes') }}
</label>
<BaseCustomInput
v-model="paymentStore.currentPayment.notes"
:content-loading="isLoadingContent"
:fields="paymentFields"
class="mt-1"
/>
</div>
<!-- Mobile Save Button -->
<BaseButton
:loading="isSaving"
:content-loading="isLoadingContent"
variant="primary"
type="submit"
class="flex justify-center w-full mt-4 sm:hidden md:hidden"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{
isEdit
? $t('payments.update_payment')
: $t('payments.save_payment')
}}
</BaseButton>
</BaseCard>
</form>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '../store'
import type { Invoice } from '../../../../types/domain/invoice'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const paymentStore = usePaymentStore()
const isSaving = ref<boolean>(false)
const isLoadingInvoices = ref<boolean>(false)
const invoiceList = ref<Invoice[]>([])
const selectedInvoice = ref<Invoice | null>(null)
const paymentFields = ref<string[]>([
'customer',
'company',
'customerCustom',
'payment',
'paymentCustom',
])
const amount = computed<number>({
get: () => paymentStore.currentPayment.amount / 100,
set: (value: number) => {
paymentStore.currentPayment.amount = Math.round(value * 100)
},
})
const isLoadingContent = computed<boolean>(
() => paymentStore.isFetchingInitialData,
)
const isEdit = computed<boolean>(() => route.name === 'payments.edit')
const pageTitle = computed<string>(() => {
return isEdit.value ? t('payments.edit_payment') : t('payments.new_payment')
})
// Reset state on create
paymentStore.resetCurrentPayment()
if (route.query.customer) {
paymentStore.currentPayment.customer_id = Number(route.query.customer)
}
paymentStore.fetchPaymentInitialData(isEdit.value, {
id: route.params.id as string | undefined,
})
function onSelectInvoice(id: number): void {
if (id) {
selectedInvoice.value =
invoiceList.value.find((inv) => inv.id === id) ?? null
if (selectedInvoice.value) {
amount.value = selectedInvoice.value.due_amount / 100
paymentStore.currentPayment.maxPayableAmount =
selectedInvoice.value.due_amount
}
}
}
function selectNewCustomer(id: number | null): void {
if (!id) return
const params: Record<string, unknown> = { userId: id }
if (route.params.id) {
params.model_id = route.params.id
}
paymentStore.currentPayment.invoice_id = null
selectedInvoice.value = null
paymentStore.currentPayment.amount = 0
invoiceList.value = []
paymentStore.getNextNumber(params, true)
}
async function submitPaymentData(): Promise<void> {
isSaving.value = true
const data = {
...paymentStore.currentPayment,
}
try {
const action = isEdit.value
? paymentStore.updatePayment
: paymentStore.addPayment
const response = await action(data)
router.push(`/admin/payments/${response.data.data.id}/view`)
} catch {
isSaving.value = false
}
}
onBeforeUnmount(() => {
paymentStore.resetCurrentPayment()
invoiceList.value = []
})
</script>

View File

@@ -0,0 +1,354 @@
<template>
<BasePage class="xl:pl-96 xl:ml-8">
<BasePageHeader :title="pageTitle">
<template #actions>
<BaseButton
v-if="canSend"
:content-loading="isFetching"
variant="primary"
@click="onPaymentSend"
>
{{ $t('payments.send_payment_receipt') }}
</BaseButton>
<PaymentDropdown
:content-loading="isFetching"
class="ml-3"
:row="paymentData"
:can-edit="canEdit"
:can-view="canView"
:can-delete="canDelete"
:can-send="canSend"
/>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-[6rem] ml-56 bg-surface xl:ml-64 w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-6 border border-line-default border-solid"
>
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
type="text"
@input="onSearch"
>
<BaseIcon name="MagnifyingGlassIcon" class="h-5" />
</BaseInput>
<div class="flex ml-3" role="group">
<BaseDropdown
position="bottom-start"
width-class="w-50"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FunnelIcon" />
</BaseButton>
</template>
<div
class="px-4 py-1 pb-2 mb-2 text-sm border-b border-line-default border-solid"
>
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
v-model="searchData.orderByField"
:label="$t('payments.date')"
size="sm"
name="filter"
value="payment_date"
@update:model-value="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
v-model="searchData.orderByField"
:label="$t('payments.payment_number')"
size="sm"
name="filter"
value="payment_number"
@update:model-value="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</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="paymentListSection"
class="h-full overflow-y-scroll border-l border-line-default border-solid base-scroll"
>
<div v-for="(payment, index) in paymentList" :key="index">
<router-link
v-if="payment"
:id="'payment-' + payment.id"
:to="`/admin/payments/${payment.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-hover-strong border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-primary-500 border-solid':
hasActiveUrl(payment.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<BaseText
:text="payment.customer?.name ?? ''"
class="pr-2 mb-2 text-sm not-italic font-normal leading-5 text-heading capitalize truncate"
/>
<div
class="mb-1 text-xs not-italic font-medium leading-5 text-muted capitalize"
>
{{ payment.payment_number }}
</div>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
class="block mb-2 text-xl not-italic font-semibold leading-8 text-right text-heading"
:amount="payment.amount"
:currency="payment.customer?.currency"
/>
<div class="text-sm text-right text-muted non-italic">
{{ payment.formatted_payment_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="!paymentList?.length && !isLoading"
class="flex justify-center px-4 mt-5 text-sm text-body"
>
{{ $t('payments.no_matching_payments') }}
</p>
</div>
</div>
<!-- PDF Preview -->
<div
class="flex flex-col min-h-0 mt-8 overflow-hidden"
style="height: 75vh"
>
<iframe
v-if="shareableLink"
:src="shareableLink"
class="flex-1 border border-gray-400 border-solid rounded-md"
/>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '../store'
import PaymentDropdown from '../components/PaymentDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import type { Payment } from '../../../../types/domain/payment'
interface Props {
canEdit?: boolean
canView?: boolean
canDelete?: boolean
canSend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canView: false,
canDelete: false,
canSend: false,
})
const paymentStore = usePaymentStore()
const { t } = useI18n()
const route = useRoute()
const paymentData = ref<Payment | Record<string, unknown>>({})
const isFetching = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const paymentList = ref<Payment[] | null>(null)
const currentPageNumber = ref<number>(1)
const lastPageNumber = ref<number>(1)
const paymentListSection = 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>(() => {
return (paymentData.value as Payment).payment_number ?? ''
})
const getOrderBy = computed<boolean>(() => {
return searchData.orderBy === 'asc' || searchData.orderBy === null
})
const shareableLink = computed<string | false>(() => {
const hash = (paymentData.value as Payment).unique_hash
return hash ? `/payments/pdf/${hash}` : false
})
watch(route, () => {
loadPayment()
})
loadPayments()
loadPayment()
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function onSearch(): void {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
paymentList.value = []
loadPayments()
}, 500)
}
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadPayments(
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 paymentStore.fetchPayments({
page: pageNumber,
...params,
} as never)
isLoading.value = false
paymentList.value = paymentList.value ?? []
paymentList.value = [...paymentList.value, ...response.data.data]
currentPageNumber.value = pageNumber ?? 1
lastPageNumber.value = response.data.meta.last_page
const paymentFound = paymentList.value.find(
(p) => p.id === Number(route.params.id),
)
if (
!fromScrollListener &&
!paymentFound &&
currentPageNumber.value < lastPageNumber.value &&
Object.keys(params).length === 0
) {
loadPayments(++currentPageNumber.value)
}
if (paymentFound && !fromScrollListener) {
setTimeout(() => scrollToPayment(), 500)
}
}
async function loadPayment(): Promise<void> {
if (!route.params.id) return
isFetching.value = true
const response = await paymentStore.fetchPayment(Number(route.params.id))
if (response.data) {
isFetching.value = false
paymentData.value = { ...response.data.data } as Payment
}
}
function scrollToPayment(): void {
const el = document.getElementById(`payment-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
addScrollListener()
}
}
function addScrollListener(): void {
paymentListSection.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) {
loadPayments(++currentPageNumber.value, true)
}
}
})
}
function sortData(): void {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
} else {
searchData.orderBy = 'asc'
}
onSearch()
}
function onPaymentSend(): void {
const modalStore = (window as Record<string, unknown>).__modalStore as
| { openModal: (opts: Record<string, unknown>) => void }
| undefined
modalStore?.openModal({
title: t('payments.send_payment'),
componentName: 'SendPaymentModal',
id: (paymentData.value as Payment).id,
data: paymentData.value,
variant: 'lg',
})
}
</script>

View File

@@ -0,0 +1,382 @@
<template>
<BasePage class="payments">
<BasePageHeader :title="$t('payments.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('payments.payment', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="paymentStore.paymentTotalCount"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="canCreate"
variant="primary"
class="ml-4"
@click="$router.push('/admin/payments/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('payments.add_payment') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Filters -->
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup :label="$t('payments.customer')">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('payments.payment_number')">
<BaseInput v-model="filters.payment_number">
<template #left="slotProps">
<BaseIcon name="HashtagIcon" :class="slotProps.class" />
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup :label="$t('payments.payment_mode')">
<BaseMultiselect
v-model="filters.payment_mode"
value-prop="id"
track-by="name"
:filter-results="false"
label="name"
resolve-on-load
:delay="500"
searchable
:options="searchPaymentMode"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<!-- Empty State -->
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('payments.no_payments')"
:description="$t('payments.list_of_payments')"
>
<template v-if="canCreate" #actions>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/payments/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('payments.add_new_payment') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<!-- Table -->
<div v-show="!showEmptyScreen" class="relative table-container">
<div class="relative flex items-center justify-end h-5">
<BaseDropdown v-if="paymentStore.selectedPayments.length && canDelete">
<template #activator>
<span
class="flex text-sm font-medium cursor-pointer select-none text-primary-400"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultiplePayments">
<BaseIcon name="TrashIcon" class="mr-3 text-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="paymentColumns"
:placeholder-count="paymentStore.paymentTotalCount >= 20 ? 10 : 5"
class="mt-3"
>
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="selectAllFieldStatus"
variant="primary"
@change="paymentStore.selectAllPayments"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-payment_date="{ row }">
{{ row.data.formatted_payment_date }}
</template>
<template #cell-payment_number="{ row }">
<router-link
:to="{ path: `payments/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.payment_number }}
</router-link>
</template>
<template #cell-name="{ row }">
<BaseText :text="row.data.customer.name" tag="span" />
</template>
<template #cell-payment_mode="{ row }">
<span>
{{ row.data.payment_method ? row.data.payment_method.name : '-' }}
</span>
</template>
<template #cell-invoice_number="{ row }">
<span>
{{ row.data.invoice?.invoice_number ?? '-' }}
</span>
</template>
<template #cell-amount="{ row }">
<BaseFormatMoney
:amount="row.data.amount"
:currency="row.data.customer.currency"
/>
</template>
<template v-if="hasAtLeastOneAbility" #cell-actions="{ row }">
<PaymentDropdown
:row="row.data"
:table="tableRef"
:can-edit="canEdit"
:can-view="canView"
: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 { usePaymentStore } from '../store'
import PaymentDropdown from '../components/PaymentDropdown.vue'
import type { Payment, PaymentMethod } from '../../../../types/domain/payment'
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 paymentStore = usePaymentStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const showFilters = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(true)
interface PaymentFilters {
customer_id: string | number
payment_mode: string | number
payment_number: string
}
const filters = reactive<PaymentFilters>({
customer_id: '',
payment_mode: '',
payment_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !paymentStore.paymentTotalCount && !isFetchingInitialData.value,
)
const hasAtLeastOneAbility = computed<boolean>(() => {
return props.canDelete || props.canEdit || props.canView || props.canSend
})
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const paymentColumns = computed<TableColumn[]>(() => [
{
key: 'status',
sortable: false,
thClass: 'extra w-10',
tdClass: 'text-left text-sm font-medium extra',
},
{
key: 'payment_date',
label: t('payments.date'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ key: 'payment_number', label: t('payments.payment_number') },
{ key: 'name', label: t('payments.customer') },
{ key: 'payment_mode', label: t('payments.payment_mode') },
{ key: 'invoice_number', label: t('payments.invoice') },
{ key: 'amount', label: t('payments.amount') },
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const selectField = computed<number[]>({
get: () => paymentStore.selectedPayments,
set: (value: number[]) => {
paymentStore.selectPayment(value)
},
})
const selectAllFieldStatus = computed<boolean>({
get: () => paymentStore.selectAllField,
set: (value: boolean) => {
paymentStore.setSelectAllState(value)
},
})
debouncedWatch(filters, () => setFilters(), { debounce: 500 })
onUnmounted(() => {
if (paymentStore.selectAllField) {
paymentStore.selectAllPayments()
}
})
paymentStore.fetchPaymentModes({ limit: 'all' })
async function searchPaymentMode(search: string): Promise<PaymentMethod[]> {
const res = await paymentStore.fetchPaymentModes({ search })
return res.data.data
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: Payment[]
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,
payment_method_id: filters.payment_mode
? Number(filters.payment_mode)
: undefined,
payment_number: filters.payment_number || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await paymentStore.fetchPayments(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable(): void {
tableRef.value?.refresh()
}
function setFilters(): void {
refreshTable()
}
function clearFilter(): void {
filters.customer_id = ''
filters.payment_mode = ''
filters.payment_number = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
async function removeMultiplePayments(): Promise<void> {
const confirmed = window.confirm(t('payments.confirm_delete'))
if (!confirmed) return
const res = await paymentStore.deleteMultiplePayments()
if (res.data.success) {
refreshTable()
}
}
</script>

View File

@@ -0,0 +1,265 @@
<template>
<div class="col-span-5 pr-0">
<BaseCustomerSelectPopup
v-model="recurringInvoiceStore.newRecurringInvoice.customer"
:content-loading="isLoading"
type="recurring-invoice"
/>
<div class="flex mt-7">
<div class="relative w-20 mt-8">
<BaseSwitch
v-model="recurringInvoiceStore.newRecurringInvoice.send_automatically"
class="absolute -top-4"
/>
</div>
<div class="ml-2">
<p class="p-0 mb-1 leading-snug text-left text-heading">
{{ $t('recurring_invoices.send_automatically') }}
</p>
<p
class="p-0 m-0 text-xs leading-tight text-left text-muted"
style="max-width: 480px"
>
{{ $t('recurring_invoices.send_automatically_desc') }}
</p>
</div>
</div>
</div>
<div
class="grid grid-cols-1 col-span-7 gap-4 mt-8 lg:gap-6 lg:mt-0 lg:grid-cols-2 rounded-xl shadow border border-line-light bg-surface p-5"
>
<!-- Starts At -->
<BaseInputGroup
:label="$t('recurring_invoices.starts_at')"
:content-loading="isLoading"
required
>
<BaseDatePicker
v-model="recurringInvoiceStore.newRecurringInvoice.starts_at"
:content-loading="isLoading"
:calendar-button="true"
calendar-button-icon="calendar"
@change="getNextInvoiceDate()"
/>
</BaseInputGroup>
<!-- Next Invoice Date -->
<BaseInputGroup
:label="$t('recurring_invoices.next_invoice_date')"
:content-loading="isLoading"
required
>
<BaseDatePicker
v-model="recurringInvoiceStore.newRecurringInvoice.next_invoice_at"
:content-loading="isLoading"
:calendar-button="true"
:disabled="true"
:loading="isLoadingNextDate"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<!-- Limit By -->
<BaseInputGroup
:label="$t('recurring_invoices.limit_by')"
:content-loading="isLoading"
class="lg:mt-0"
required
>
<BaseMultiselect
v-model="recurringInvoiceStore.newRecurringInvoice.limit_by"
:content-loading="isLoading"
:options="limits"
label="label"
value-prop="value"
/>
</BaseInputGroup>
<!-- Limit Date -->
<BaseInputGroup
v-if="hasLimitBy('DATE')"
:label="$t('recurring_invoices.limit_date')"
:content-loading="isLoading"
:required="hasLimitBy('DATE')"
>
<BaseDatePicker
v-model="recurringInvoiceStore.newRecurringInvoice.limit_date"
:content-loading="isLoading"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<!-- Limit Count -->
<BaseInputGroup
v-if="hasLimitBy('COUNT')"
:label="$t('recurring_invoices.count')"
:content-loading="isLoading"
:required="hasLimitBy('COUNT')"
>
<BaseInput
v-model="recurringInvoiceStore.newRecurringInvoice.limit_count"
:content-loading="isLoading"
type="number"
/>
</BaseInputGroup>
<!-- Status -->
<BaseInputGroup
:label="$t('recurring_invoices.status')"
required
:content-loading="isLoading"
>
<BaseMultiselect
v-model="recurringInvoiceStore.newRecurringInvoice.status"
:options="statusOptions"
:content-loading="isLoading"
:placeholder="$t('recurring_invoices.select_a_status')"
value-prop="value"
label="key"
/>
</BaseInputGroup>
<!-- Frequency -->
<BaseInputGroup
:label="$t('recurring_invoices.frequency.select_frequency')"
required
:content-loading="isLoading"
>
<BaseMultiselect
v-model="recurringInvoiceStore.newRecurringInvoice.selectedFrequency"
:content-loading="isLoading"
:options="recurringInvoiceStore.frequencies"
label="label"
object
@change="getNextInvoiceDate"
/>
</BaseInputGroup>
<!-- Custom Frequency -->
<BaseInputGroup
v-if="isCustomFrequency"
:label="$t('recurring_invoices.frequency.title')"
:content-loading="isLoading"
required
>
<BaseInput
v-model="recurringInvoiceStore.newRecurringInvoice.frequency"
:content-loading="isLoading"
:disabled="!isCustomFrequency"
:loading="isLoadingNextDate"
@update:model-value="debounceNextDate"
/>
</BaseInputGroup>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { useRecurringInvoiceStore } from '../store'
import type { FrequencyOption } from '../store'
interface Props {
isLoading?: boolean
isEdit?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
isEdit: false,
})
const route = useRoute()
const recurringInvoiceStore = useRecurringInvoiceStore()
const { t } = useI18n()
const isLoadingNextDate = ref<boolean>(false)
interface LimitOption {
label: string
value: string
}
const limits = reactive<LimitOption[]>([
{ label: t('recurring_invoices.limit.none'), value: 'NONE' },
{ label: t('recurring_invoices.limit.date'), value: 'DATE' },
{ label: t('recurring_invoices.limit.count'), value: 'COUNT' },
])
interface StatusOption {
key: string
value: string
}
const statusOptions = computed<StatusOption[]>(() => {
if (props.isEdit) {
return [
{ key: t('recurring_invoices.active'), value: 'ACTIVE' },
{ key: t('recurring_invoices.on_hold'), value: 'ON_HOLD' },
{ key: t('recurring_invoices.completed'), value: 'COMPLETED' },
]
}
return [
{ key: t('recurring_invoices.active'), value: 'ACTIVE' },
{ key: t('recurring_invoices.on_hold'), value: 'ON_HOLD' },
]
})
const isCustomFrequency = computed<boolean>(() => {
return (
recurringInvoiceStore.newRecurringInvoice.selectedFrequency != null &&
recurringInvoiceStore.newRecurringInvoice.selectedFrequency.value ===
'CUSTOM'
)
})
watch(
() => recurringInvoiceStore.newRecurringInvoice.selectedFrequency,
(newValue: FrequencyOption | null) => {
if (!recurringInvoiceStore.isFetchingInitialSettings) {
if (newValue && newValue.value !== 'CUSTOM') {
recurringInvoiceStore.newRecurringInvoice.frequency = newValue.value
} else {
recurringInvoiceStore.newRecurringInvoice.frequency = null
}
}
},
)
onMounted(() => {
if (!route.params.id) {
getNextInvoiceDate()
}
})
function hasLimitBy(limitBy: string): boolean {
return recurringInvoiceStore.newRecurringInvoice.limit_by === limitBy
}
const debounceNextDate = useDebounceFn(() => {
getNextInvoiceDate()
}, 500)
async function getNextInvoiceDate(): Promise<void> {
const val = recurringInvoiceStore.newRecurringInvoice.frequency
if (!val) return
isLoadingNextDate.value = true
try {
await recurringInvoiceStore.fetchRecurringInvoiceFrequencyDate({
starts_at: recurringInvoiceStore.newRecurringInvoice.starts_at,
frequency: val,
})
} catch {
// Error handled in store
} finally {
isLoadingNextDate.value = false
}
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<BaseDropdown :content-loading="recurringInvoiceStore.isFetchingViewData">
<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 Recurring Invoice -->
<router-link
v-if="canEdit"
:to="`/admin/recurring-invoices/${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 Recurring Invoice -->
<router-link
v-if="!isDetailView && canView"
:to="`recurring-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>
<!-- Delete Recurring Invoice -->
<BaseDropdownItem v-if="canDelete" @click="removeRecurringInvoice">
<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 { useRecurringInvoiceStore } from '../store'
import type { RecurringInvoice } from '../../../../types/domain/recurring-invoice'
interface TableRef {
refresh: () => void
}
interface Props {
row: RecurringInvoice | Record<string, unknown>
table?: TableRef | null
loadData?: (() => void) | null
canEdit?: boolean
canView?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<Props>(), {
table: null,
loadData: null,
canEdit: false,
canView: false,
canDelete: false,
})
const recurringInvoiceStore = useRecurringInvoiceStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isDetailView = computed<boolean>(
() => route.name === 'recurring-invoices.view',
)
async function removeRecurringInvoice(): Promise<void> {
const confirmed = window.confirm(t('invoices.confirm_delete'))
if (!confirmed) return
const invoiceRow = props.row as RecurringInvoice
const res = await recurringInvoiceStore.deleteMultipleRecurringInvoices(
invoiceRow.id,
)
if (res.data.success) {
props.table?.refresh()
recurringInvoiceStore.$patch((state) => {
state.selectedRecurringInvoices = []
state.selectAllField = false
})
if (isDetailView.value) {
router.push('/admin/recurring-invoices')
}
}
}
</script>

View File

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

View File

@@ -0,0 +1,18 @@
export { useRecurringInvoiceStore } from './store'
export type {
RecurringInvoiceStore,
RecurringInvoiceFormData,
RecurringInvoiceState,
FrequencyOption,
} from './store'
export { recurringInvoiceRoutes } from './routes'
// Views
export { default as RecurringInvoiceIndexView } from './views/RecurringInvoiceIndexView.vue'
export { default as RecurringInvoiceCreateView } from './views/RecurringInvoiceCreateView.vue'
export { default as RecurringInvoiceDetailView } from './views/RecurringInvoiceDetailView.vue'
// Components
export { default as RecurringInvoiceBasicFields } from './components/RecurringInvoiceBasicFields.vue'
export { default as RecurringInvoiceDropdown } from './components/RecurringInvoiceDropdown.vue'
export { default as RecurringInvoiceStatusBadge } from './components/RecurringInvoiceStatusBadge.vue'

View File

@@ -0,0 +1,47 @@
import type { RouteRecordRaw } from 'vue-router'
const RecurringInvoiceIndexView = () =>
import('./views/RecurringInvoiceIndexView.vue')
const RecurringInvoiceCreateView = () =>
import('./views/RecurringInvoiceCreateView.vue')
const RecurringInvoiceDetailView = () =>
import('./views/RecurringInvoiceDetailView.vue')
export const recurringInvoiceRoutes: RouteRecordRaw[] = [
{
path: 'recurring-invoices',
name: 'recurring-invoices.index',
component: RecurringInvoiceIndexView,
meta: {
ability: 'view-recurring-invoice',
title: 'recurring_invoices.title',
},
},
{
path: 'recurring-invoices/create',
name: 'recurring-invoices.create',
component: RecurringInvoiceCreateView,
meta: {
ability: 'create-recurring-invoice',
title: 'recurring_invoices.new_invoice',
},
},
{
path: 'recurring-invoices/:id/edit',
name: 'recurring-invoices.edit',
component: RecurringInvoiceCreateView,
meta: {
ability: 'edit-recurring-invoice',
title: 'recurring_invoices.edit_invoice',
},
},
{
path: 'recurring-invoices/:id/view',
name: 'recurring-invoices.view',
component: RecurringInvoiceDetailView,
meta: {
ability: 'view-recurring-invoice',
title: 'recurring_invoices.title',
},
},
]

View File

@@ -0,0 +1,587 @@
import { defineStore } from 'pinia'
import { recurringInvoiceService } from '../../../api/services/recurring-invoice.service'
import type {
RecurringInvoiceListParams,
RecurringInvoiceListResponse,
FrequencyDateParams,
} from '../../../api/services/recurring-invoice.service'
import type {
RecurringInvoice,
RecurringInvoiceLimitBy,
} from '../../../types/domain/recurring-invoice'
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'
// ----------------------------------------------------------------
// Frequency options
// ----------------------------------------------------------------
export interface FrequencyOption {
label: string
value: string
}
// ----------------------------------------------------------------
// 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 createRecurringInvoiceItemStub(): DocumentItem {
return {
id: crypto.randomUUID(),
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 RecurringInvoiceFormData {
id: number | null
customer: Customer | null
customer_id: number | null
template_name: string | null
starts_at: string
next_invoice_at: string
next_invoice_date: string
frequency: string | null
selectedFrequency: FrequencyOption | null
status: string
limit_by: RecurringInvoiceLimitBy | string
limit_count: number | null
limit_date: string | null
send_automatically: boolean
notes: string | null
discount: number
discount_type: DiscountType
discount_val: number
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
currency: Currency | Record<string, unknown> | null
currency_id: number | null
exchange_rate: number | null
unique_hash?: string
invoices?: Invoice[]
}
function createRecurringInvoiceStub(): RecurringInvoiceFormData {
return {
id: null,
customer: null,
customer_id: null,
template_name: null,
starts_at: '',
next_invoice_at: '',
next_invoice_date: '',
frequency: null,
selectedFrequency: null,
status: 'ACTIVE',
limit_by: 'NONE',
limit_count: null,
limit_date: null,
send_automatically: false,
notes: '',
discount: 0,
discount_type: 'fixed',
discount_val: 0,
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: [createRecurringInvoiceItemStub()],
customFields: [],
fields: [],
selectedNote: null,
currency: null,
currency_id: null,
exchange_rate: null,
invoices: [],
}
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface RecurringInvoiceState {
templates: { name: string; path?: string }[]
recurringInvoices: RecurringInvoice[]
selectedRecurringInvoices: number[]
totalRecurringInvoices: number
isFetchingInitialSettings: boolean
isFetchingInvoice: boolean
isFetchingViewData: boolean
showExchangeRate: boolean
selectAllField: boolean
newRecurringInvoice: RecurringInvoiceFormData
frequencies: FrequencyOption[]
}
export const useRecurringInvoiceStore = defineStore('recurring-invoice', {
state: (): RecurringInvoiceState => ({
templates: [],
recurringInvoices: [],
selectedRecurringInvoices: [],
totalRecurringInvoices: 0,
isFetchingInitialSettings: false,
isFetchingInvoice: false,
isFetchingViewData: false,
showExchangeRate: false,
selectAllField: false,
newRecurringInvoice: createRecurringInvoiceStub(),
frequencies: [],
}),
getters: {
getSubTotal(state): number {
return state.newRecurringInvoice.items.reduce(
(sum: number, item: DocumentItem) => sum + (item.total ?? 0),
0,
)
},
getNetTotal(): number {
return this.getSubtotalWithDiscount - this.getTotalTax
},
getTotalSimpleTax(state): number {
return state.newRecurringInvoice.taxes.reduce(
(sum: number, tax: DocumentTax) => {
if (!tax.compound_tax) return sum + (tax.amount ?? 0)
return sum
},
0,
)
},
getTotalCompoundTax(state): number {
return state.newRecurringInvoice.taxes.reduce(
(sum: number, tax: DocumentTax) => {
if (tax.compound_tax) return sum + (tax.amount ?? 0)
return sum
},
0,
)
},
getTotalTax(): number {
if (
this.newRecurringInvoice.tax_per_item === 'NO' ||
this.newRecurringInvoice.tax_per_item === null
) {
return this.getTotalSimpleTax + this.getTotalCompoundTax
}
return this.newRecurringInvoice.items.reduce(
(sum: number, item: DocumentItem) => sum + (item.tax ?? 0),
0,
)
},
getSubtotalWithDiscount(): number {
return this.getSubTotal - this.newRecurringInvoice.discount_val
},
getTotal(): number {
if (this.newRecurringInvoice.tax_included) {
return this.getSubtotalWithDiscount
}
return this.getSubtotalWithDiscount + this.getTotalTax
},
},
actions: {
initFrequencies(t: (key: string) => string): void {
this.frequencies = [
{
label: t('recurring_invoices.frequency.every_minute'),
value: '* * * * *',
},
{
label: t('recurring_invoices.frequency.every_30_minute'),
value: '*/30 * * * *',
},
{
label: t('recurring_invoices.frequency.every_hour'),
value: '0 * * * *',
},
{
label: t('recurring_invoices.frequency.every_2_hour'),
value: '0 */2 * * *',
},
{
label: t('recurring_invoices.frequency.every_day_at_midnight'),
value: '0 0 * * *',
},
{
label: t('recurring_invoices.frequency.every_week'),
value: '0 0 * * 0',
},
{
label: t(
'recurring_invoices.frequency.every_15_days_at_midnight',
),
value: '0 5 */15 * *',
},
{
label: t(
'recurring_invoices.frequency.on_the_first_day_of_every_month_at_midnight',
),
value: '0 0 1 * *',
},
{
label: t('recurring_invoices.frequency.every_6_month'),
value: '0 0 1 */6 *',
},
{
label: t(
'recurring_invoices.frequency.every_year_on_the_first_day_of_january_at_midnight',
),
value: '0 0 1 1 *',
},
{
label: t('recurring_invoices.frequency.custom'),
value: 'CUSTOM',
},
]
},
resetCurrentRecurringInvoice(): void {
this.newRecurringInvoice = createRecurringInvoiceStub()
},
deselectItem(index: number): void {
this.newRecurringInvoice.items[index] = {
...createRecurringInvoiceItemStub(),
taxes: [createTaxStub()],
}
},
addItem(): void {
this.newRecurringInvoice.items.push({
...createRecurringInvoiceItemStub(),
taxes: [createTaxStub()],
})
},
removeItem(index: number): void {
this.newRecurringInvoice.items.splice(index, 1)
},
updateItem(data: DocumentItem & { index: number }): void {
Object.assign(this.newRecurringInvoice.items[data.index], { ...data })
},
setTemplate(name: string): void {
this.newRecurringInvoice.template_name = name
},
setSelectedFrequency(): void {
const found = this.frequencies.find(
(f) => f.value === this.newRecurringInvoice.frequency,
)
if (found) {
this.newRecurringInvoice.selectedFrequency = found
} else {
this.newRecurringInvoice.selectedFrequency = {
label: 'Custom',
value: 'CUSTOM',
}
}
},
resetSelectedNote(): void {
this.newRecurringInvoice.selectedNote = null
},
selectNote(data: Note): void {
this.newRecurringInvoice.selectedNote = null
this.newRecurringInvoice.selectedNote = data
},
resetSelectedCustomer(): void {
this.newRecurringInvoice.customer = null
this.newRecurringInvoice.customer_id = null
},
async selectCustomer(id: number): Promise<unknown> {
const { customerService } = await import(
'../../../api/services/customer.service'
)
const response = await customerService.get(id)
this.newRecurringInvoice.customer =
response.data as unknown as Customer
this.newRecurringInvoice.customer_id = response.data.id
return response
},
async fetchRecurringInvoices(
params: RecurringInvoiceListParams & {
from_date?: string
to_date?: string
},
): Promise<{ data: RecurringInvoiceListResponse }> {
const response = await recurringInvoiceService.list(params)
this.recurringInvoices = response.data
this.totalRecurringInvoices =
response.meta.recurring_invoice_total_count
return { data: response }
},
async fetchRecurringInvoice(
id: number,
): Promise<{ data: { data: RecurringInvoice } }> {
this.isFetchingViewData = true
try {
const response = await recurringInvoiceService.get(id)
Object.assign(this.newRecurringInvoice, response.data)
this.newRecurringInvoice.invoices = response.data.invoices ?? []
this.setSelectedFrequency()
this.isFetchingViewData = false
return { data: response }
} catch (err) {
this.isFetchingViewData = false
throw err
}
},
async addRecurringInvoice(
data: Record<string, unknown>,
): Promise<{ data: { data: RecurringInvoice } }> {
const response = await recurringInvoiceService.create(data as never)
this.recurringInvoices = [
...this.recurringInvoices,
response.data,
]
return { data: response }
},
async updateRecurringInvoice(
data: Record<string, unknown>,
): Promise<{ data: { data: RecurringInvoice } }> {
const response = await recurringInvoiceService.update(
data.id as number,
data as never,
)
const pos = this.recurringInvoices.findIndex(
(inv) => inv.id === response.data.id,
)
if (pos !== -1) {
this.recurringInvoices[pos] = response.data
}
return { data: response }
},
async deleteRecurringInvoice(
payload: { ids: number[] },
): Promise<{ data: { success: boolean } }> {
const response = await recurringInvoiceService.delete(payload)
const id = payload.ids[0]
const index = this.recurringInvoices.findIndex((inv) => inv.id === id)
if (index !== -1) {
this.recurringInvoices.splice(index, 1)
}
return { data: response }
},
async deleteMultipleRecurringInvoices(
singleId?: number | null,
): Promise<{ data: { success: boolean } }> {
const ids = singleId
? [singleId]
: this.selectedRecurringInvoices
const response = await recurringInvoiceService.delete({ ids })
this.selectedRecurringInvoices.forEach((invoiceId) => {
const index = this.recurringInvoices.findIndex(
(inv) => inv.id === invoiceId,
)
if (index !== -1) {
this.recurringInvoices.splice(index, 1)
}
})
this.selectedRecurringInvoices = []
return { data: response }
},
async fetchRecurringInvoiceFrequencyDate(
params: FrequencyDateParams,
): Promise<void> {
const response =
await recurringInvoiceService.getFrequencyDate(params)
this.newRecurringInvoice.next_invoice_at =
response.next_invoice_at
},
selectRecurringInvoice(data: number[]): void {
this.selectedRecurringInvoices = data
this.selectAllField =
this.selectedRecurringInvoices.length ===
this.recurringInvoices.length
},
selectAllRecurringInvoices(): void {
if (
this.selectedRecurringInvoices.length ===
this.recurringInvoices.length
) {
this.selectedRecurringInvoices = []
this.selectAllField = false
} else {
this.selectedRecurringInvoices = this.recurringInvoices.map(
(inv) => inv.id,
)
this.selectAllField = true
}
},
addSalesTaxUs(taxTypes: TaxType[]): void {
const salesTax = createTaxStub()
const found = this.newRecurringInvoice.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 fetchRecurringInvoiceInitialSettings(
isEdit: boolean,
routeParams?: { id?: string; query?: Record<string, string> },
companySettings?: Record<string, string>,
companyCurrency?: Currency,
): Promise<void> {
this.isFetchingInitialSettings = true
if (companyCurrency) {
this.newRecurringInvoice.currency = companyCurrency
}
if (routeParams?.query?.customer) {
try {
await this.selectCustomer(
Number(routeParams.query.customer),
)
} catch {
// Silently fail
}
}
const editActions: Promise<unknown>[] = []
if (!isEdit && companySettings) {
this.newRecurringInvoice.tax_per_item =
companySettings.tax_per_item ?? null
this.newRecurringInvoice.discount_per_item =
companySettings.discount_per_item ?? null
this.newRecurringInvoice.sales_tax_type =
companySettings.sales_tax_type ?? null
this.newRecurringInvoice.sales_tax_address_type =
companySettings.sales_tax_address_type ?? null
this.newRecurringInvoice.starts_at = formatDate(new Date())
this.newRecurringInvoice.next_invoice_date = formatDate(
addDays(new Date(), 7),
)
} else if (isEdit && routeParams?.id) {
editActions.push(
this.fetchRecurringInvoice(Number(routeParams.id)),
)
}
try {
const [, , , , editRes] = await Promise.all([
Promise.resolve(), // placeholder for items fetch
this.resetSelectedNote(),
Promise.resolve(), // placeholder for invoice templates
Promise.resolve(), // placeholder for tax types fetch
...editActions,
])
if (!isEdit) {
if (this.templates.length) {
this.setTemplate(this.templates[0].name)
}
} else if (editRes) {
const res = editRes as { data: { data: RecurringInvoice } }
if (res?.data?.data?.template_name) {
this.setTemplate(res.data.data.template_name)
}
this.addSalesTaxUs([])
}
} catch {
// Error handling
} finally {
this.isFetchingInitialSettings = false
}
},
},
})
function formatDate(date: Date): 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}`
}
function addDays(date: Date, days: number): Date {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
export type RecurringInvoiceStore = ReturnType<typeof useRecurringInvoiceStore>

View File

@@ -0,0 +1,130 @@
<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('recurring_invoices.title', 2)"
to="/admin/recurring-invoices"
/>
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('recurring_invoices.save_invoice') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Select Customer & Basic Fields -->
<div class="grid-cols-12 gap-8 mt-6 mb-8 lg:grid">
<RecurringInvoiceBasicFields
:is-loading="isLoadingContent"
:is-edit="isEdit"
/>
</div>
</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 { useRecurringInvoiceStore } from '../store'
import RecurringInvoiceBasicFields from '../components/RecurringInvoiceBasicFields.vue'
const recurringInvoiceStore = useRecurringInvoiceStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isSaving = ref<boolean>(false)
const isLoadingContent = computed<boolean>(
() =>
recurringInvoiceStore.isFetchingInvoice ||
recurringInvoiceStore.isFetchingInitialSettings,
)
const pageTitle = computed<string>(() =>
isEdit.value
? t('recurring_invoices.edit_invoice')
: t('recurring_invoices.new_invoice'),
)
const isEdit = computed<boolean>(
() => route.name === 'recurring-invoices.edit',
)
// Initialize frequencies
recurringInvoiceStore.initFrequencies(t)
// Reset state
recurringInvoiceStore.resetCurrentRecurringInvoice()
recurringInvoiceStore.fetchRecurringInvoiceInitialSettings(isEdit.value, {
id: route.params.id as string | undefined,
query: route.query as Record<string, string>,
})
watch(
() => recurringInvoiceStore.newRecurringInvoice.customer,
(newVal) => {
if (newVal && (newVal as Record<string, unknown>).currency) {
recurringInvoiceStore.newRecurringInvoice.currency = (
newVal as Record<string, unknown>
).currency as typeof recurringInvoiceStore.newRecurringInvoice.currency
}
},
)
async function submitForm(): Promise<void> {
isSaving.value = true
const data: Record<string, unknown> = {
...recurringInvoiceStore.newRecurringInvoice,
sub_total: recurringInvoiceStore.getSubTotal,
total: recurringInvoiceStore.getTotal,
tax: recurringInvoiceStore.getTotalTax,
}
try {
if (route.params.id) {
const res = await recurringInvoiceStore.updateRecurringInvoice(data)
if (res.data.data) {
router.push(
`/admin/recurring-invoices/${res.data.data.id}/view`,
)
}
} else {
const res = await recurringInvoiceStore.addRecurringInvoice(data)
if (res.data.data) {
router.push(
`/admin/recurring-invoices/${res.data.data.id}/view`,
)
}
}
} catch {
// Error handled in store
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,127 @@
<template>
<BasePage class="xl:pl-96 xl:ml-8">
<BasePageHeader :title="pageTitle">
<template #actions>
<RecurringInvoiceDropdown
v-if="hasAtLeastOneAbility"
:row="recurringInvoiceStore.newRecurringInvoice"
:can-edit="canEdit"
:can-view="canView"
:can-delete="canDelete"
/>
</template>
</BasePageHeader>
<!-- Content loaded from partials / child components would go here -->
<div class="mt-8">
<div
v-if="recurringInvoiceStore.isFetchingViewData"
class="flex justify-center p-12"
>
<LoadingIcon class="h-8 animate-spin text-primary-400" />
</div>
<div v-else>
<!-- Invoice details info would be rendered here -->
<div class="bg-surface rounded-xl border border-line-default p-6">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-muted">{{ $t('recurring_invoices.starts_at') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.starts_at }}
</span>
</div>
<div>
<span class="text-muted">{{ $t('recurring_invoices.next_invoice_date') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.next_invoice_at }}
</span>
</div>
<div>
<span class="text-muted">{{ $t('recurring_invoices.frequency.title') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.frequency }}
</span>
</div>
<div>
<span class="text-muted">{{ $t('recurring_invoices.status') }}:</span>
<span class="ml-2">
<BaseRecurringInvoiceStatusBadge
:status="recurringInvoiceStore.newRecurringInvoice.status"
class="px-2 py-0.5"
>
{{ recurringInvoiceStore.newRecurringInvoice.status }}
</BaseRecurringInvoiceStatusBadge>
</span>
</div>
<div>
<span class="text-muted">{{ $t('recurring_invoices.limit_by') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.limit_by }}
</span>
</div>
<div v-if="recurringInvoiceStore.newRecurringInvoice.limit_by === 'COUNT'">
<span class="text-muted">{{ $t('recurring_invoices.count') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.limit_count }}
</span>
</div>
<div v-if="recurringInvoiceStore.newRecurringInvoice.limit_by === 'DATE'">
<span class="text-muted">{{ $t('recurring_invoices.limit_date') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.limit_date }}
</span>
</div>
<div>
<span class="text-muted">{{ $t('recurring_invoices.send_automatically') }}:</span>
<span class="ml-2 text-heading">
{{ recurringInvoiceStore.newRecurringInvoice.send_automatically ? $t('general.yes') : $t('general.no') }}
</span>
</div>
</div>
</div>
</div>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useRecurringInvoiceStore } from '../store'
import RecurringInvoiceDropdown from '../components/RecurringInvoiceDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
interface Props {
canEdit?: boolean
canView?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canView: false,
canDelete: false,
})
const recurringInvoiceStore = useRecurringInvoiceStore()
const { t } = useI18n()
const route = useRoute()
const pageTitle = computed<string>(() => {
return recurringInvoiceStore.newRecurringInvoice?.customer?.name ?? ''
})
const hasAtLeastOneAbility = computed<boolean>(() => {
return props.canDelete || props.canEdit
})
// Initialize frequencies
recurringInvoiceStore.initFrequencies(t)
// Load the recurring invoice
if (route.params.id) {
recurringInvoiceStore.fetchRecurringInvoice(Number(route.params.id))
}
</script>

View File

@@ -0,0 +1,477 @@
<template>
<BasePage>
<BasePageHeader :title="$t('recurring_invoices.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('recurring_invoices.invoice', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="recurringInvoiceStore.totalRecurringInvoices"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link
v-if="canCreate"
to="recurring-invoices/create"
>
<BaseButton variant="primary" class="ml-4">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('recurring_invoices.new_invoice') }}
</BaseButton>
</router-link>
</template>
</BasePageHeader>
<!-- Filters -->
<BaseFilterWrapper v-show="showFilters" @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('recurring_invoices.status')">
<BaseMultiselect
v-model="filters.status"
:options="statusList"
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>
</BaseFilterWrapper>
<!-- Empty State -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('recurring_invoices.no_invoices')"
:description="$t('recurring_invoices.list_of_invoices')"
>
<template v-if="canCreate" #actions>
<BaseButton
variant="primary-outline"
@click="$router.push('/admin/recurring-invoices/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('recurring_invoices.add_new_invoice') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<!-- 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"
:default-index="currentStatusIndex"
@change="setStatusFilter"
>
<BaseTab :title="$t('recurring_invoices.all')" filter="ALL" />
<BaseTab :title="$t('recurring_invoices.active')" filter="ACTIVE" />
<BaseTab
:title="$t('recurring_invoices.on_hold')"
filter="ON_HOLD"
/>
</BaseTabGroup>
<BaseDropdown
v-if="recurringInvoiceStore.selectedRecurringInvoices.length"
class="absolute float-right"
>
<template #activator>
<span
class="flex text-sm font-medium cursor-pointer select-none text-primary-400"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" class="h-5" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleRecurringInvoices()">
<BaseIcon name="TrashIcon" class="mr-3 text-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="invoiceColumns"
:placeholder-count="
recurringInvoiceStore.totalRecurringInvoices >= 20 ? 10 : 5
"
class="mt-10"
>
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="recurringInvoiceStore.selectAllField"
variant="primary"
@change="recurringInvoiceStore.selectAllRecurringInvoices"
/>
</div>
</template>
<template #cell-checkbox="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
/>
</div>
</template>
<template #cell-starts_at="{ row }">
{{ row.data.formatted_starts_at }}
</template>
<template #cell-customer="{ row }">
<router-link
:to="{ path: `recurring-invoices/${row.data.id}/view` }"
>
<BaseText
:text="row.data.customer.name"
tag="span"
class="font-medium text-primary-500 flex flex-col"
/>
<BaseText
:text="row.data.customer.contact_name ?? ''"
tag="span"
class="text-xs text-subtle"
/>
</router-link>
</template>
<template #cell-frequency="{ row }">
{{ getFrequencyLabel(row.data.frequency) }}
</template>
<template #cell-status="{ row }">
<BaseRecurringInvoiceStatusBadge
:status="row.data.status"
class="px-3 py-1"
>
<BaseRecurringInvoiceStatusLabel :status="row.data.status" />
</BaseRecurringInvoiceStatusBadge>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<template v-if="hasAtLeastOneAbility" #cell-actions="{ row }">
<RecurringInvoiceDropdown
:row="row.data"
:table="tableRef"
:can-edit="canEdit"
:can-view="canView"
:can-delete="canDelete"
/>
</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 { useRecurringInvoiceStore } from '../store'
import RecurringInvoiceDropdown from '../components/RecurringInvoiceDropdown.vue'
import type { RecurringInvoice } from '../../../../types/domain/recurring-invoice'
interface Props {
canCreate?: boolean
canEdit?: boolean
canView?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canCreate: false,
canEdit: false,
canView: false,
canDelete: false,
})
const recurringInvoiceStore = useRecurringInvoiceStore()
const { t } = useI18n()
// Initialize frequencies with translations
recurringInvoiceStore.initFrequencies(t)
const tableRef = ref<{ refresh: () => void } | null>(null)
const showFilters = ref<boolean>(false)
const isRequestOngoing = ref<boolean>(true)
const activeTab = ref<string>('recurring-invoices.all')
interface StatusOption {
label: string
value: string
}
const statusList = ref<StatusOption[]>([
{ label: t('recurring_invoices.active'), value: 'ACTIVE' },
{ label: t('recurring_invoices.on_hold'), value: 'ON_HOLD' },
{ label: t('recurring_invoices.all'), value: 'ALL' },
])
interface RecurringInvoiceFilters {
customer_id: string | number
status: string
from_date: string
to_date: string
}
const filters = reactive<RecurringInvoiceFilters>({
customer_id: '',
status: '',
from_date: '',
to_date: '',
})
const showEmptyScreen = computed<boolean>(
() =>
!recurringInvoiceStore.totalRecurringInvoices && !isRequestOngoing.value,
)
const hasAtLeastOneAbility = computed<boolean>(() => {
return props.canDelete || props.canEdit || props.canView
})
const selectField = computed<number[]>({
get: () => recurringInvoiceStore.selectedRecurringInvoices,
set: (value: number[]) => {
recurringInvoiceStore.selectRecurringInvoice(value)
},
})
const currentStatusIndex = computed<number>(() => {
return statusList.value.findIndex(
(status) => status.value === filters.status,
)
})
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const invoiceColumns = computed<TableColumn[]>(() => [
{
key: 'checkbox',
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'starts_at',
label: t('recurring_invoices.starts_at'),
thClass: 'extra',
tdClass: 'font-medium',
},
{ key: 'customer', label: t('invoices.customer') },
{ key: 'frequency', label: t('recurring_invoices.frequency.title') },
{ key: 'status', label: t('invoices.status') },
{ key: 'total', label: t('invoices.total') },
{
key: 'actions',
label: t('recurring_invoices.action'),
tdClass: 'text-right text-sm font-medium',
thClass: 'text-right',
sortable: false,
},
])
debouncedWatch(filters, () => setFilters(), { debounce: 500 })
onUnmounted(() => {
if (recurringInvoiceStore.selectAllField) {
recurringInvoiceStore.selectAllRecurringInvoices()
}
})
function getFrequencyLabel(frequencyFormat: string): string {
const frequencyObj = recurringInvoiceStore.frequencies.find(
(f) => f.value === frequencyFormat,
)
return frequencyObj ? frequencyObj.label : `CUSTOM: ${frequencyFormat}`
}
function refreshTable(): void {
tableRef.value?.refresh()
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: RecurringInvoice[]
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,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isRequestOngoing.value = true
const response = await recurringInvoiceStore.fetchRecurringInvoices(
data as never,
)
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('recurring_invoices.active'):
filters.status = 'ACTIVE'
break
case t('recurring_invoices.on_hold'):
filters.status = 'ON_HOLD'
break
case t('recurring_invoices.all'):
filters.status = 'ALL'
break
}
}
function setFilters(): void {
recurringInvoiceStore.$patch((state) => {
state.selectedRecurringInvoices = []
state.selectAllField = false
})
refreshTable()
}
function clearFilter(): void {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
activeTab.value = t('general.all')
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function clearStatusSearch(): void {
filters.status = ''
refreshTable()
}
function setActiveTab(val: string): void {
const tabMap: Record<string, string> = {
ACTIVE: t('recurring_invoices.active'),
ON_HOLD: t('recurring_invoices.on_hold'),
ALL: t('recurring_invoices.all'),
}
activeTab.value = tabMap[val] ?? t('general.all')
}
async function removeMultipleRecurringInvoices(): Promise<void> {
const confirmed = window.confirm(t('invoices.confirm_delete'))
if (!confirmed) return
const res = await recurringInvoiceStore.deleteMultipleRecurringInvoices()
if (res.data.success) {
refreshTable()
recurringInvoiceStore.$patch((state) => {
state.selectedRecurringInvoices = []
state.selectAllField = false
})
}
}
</script>

View File

@@ -0,0 +1 @@
export { default as reportRoutes } from './routes'

View File

@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router'
const reportRoutes: RouteRecordRaw[] = [
{
path: 'reports/sales',
name: 'reports.sales',
component: () => import('./views/SalesReportView.vue'),
meta: {
ability: 'view-financial-report',
},
},
{
path: 'reports/profit-loss',
name: 'reports.profit-loss',
component: () => import('./views/ProfitLossReportView.vue'),
meta: {
ability: 'view-financial-report',
},
},
{
path: 'reports/expenses',
name: 'reports.expenses',
component: () => import('./views/ExpensesReportView.vue'),
meta: {
ability: 'view-financial-report',
},
},
{
path: 'reports/taxes',
name: 'reports.taxes',
component: () => import('./views/TaxReportView.vue'),
meta: {
ability: 'view-financial-report',
},
},
]
export default reportRoutes

View File

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

View File

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

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import moment from 'moment'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '../../../../stores/company.store'
import { useGlobalStore } from '../../../../stores/global.store'
interface DateRangeOption {
label: string
key: string
}
interface ReportTypeOption {
label: string
value: string
}
interface ReportFormData {
from_date: string
to_date: string
}
const { t } = useI18n()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const dateRange = reactive<DateRangeOption[]>([
{ label: t('dateRange.today'), key: 'Today' },
{ label: t('dateRange.this_week'), key: 'This Week' },
{ label: t('dateRange.this_month'), key: 'This Month' },
{ label: t('dateRange.this_quarter'), key: 'This Quarter' },
{ label: t('dateRange.this_year'), key: 'This Year' },
{ label: t('dateRange.previous_week'), key: 'Previous Week' },
{ label: t('dateRange.previous_month'), key: 'Previous Month' },
{ label: t('dateRange.previous_quarter'), key: 'Previous Quarter' },
{ label: t('dateRange.previous_year'), key: 'Previous Year' },
{ label: t('dateRange.custom'), key: 'Custom' },
])
const selectedRange = ref<DateRangeOption>(dateRange[2])
const reportTypes = ref<ReportTypeOption[]>([
{ label: t('reports.sales.sort.by_customer'), value: 'By Customer' },
{ label: t('reports.sales.sort.by_item'), value: 'By Item' },
])
const selectedType = ref<string>('By Customer')
const url = ref<string | null>(null)
const customerSiteURL = ref<string | null>(null)
const itemsSiteURL = ref<string | null>(null)
const formData = reactive<ReportFormData>({
from_date: moment().startOf('month').format('YYYY-MM-DD'),
to_date: moment().endOf('month').format('YYYY-MM-DD'),
})
const getReportUrl = computed<string | null>(() => url.value)
const selectedCompany = computed(() => companyStore.selectedCompany)
const customerDateRangeUrl = computed<string>(() => {
return `${customerSiteURL.value}?from_date=${moment(formData.from_date).format(
'YYYY-MM-DD'
)}&to_date=${moment(formData.to_date).format('YYYY-MM-DD')}`
})
const itemDateRangeUrl = computed<string>(() => {
return `${itemsSiteURL.value}?from_date=${moment(formData.from_date).format(
'YYYY-MM-DD'
)}&to_date=${moment(formData.to_date).format('YYYY-MM-DD')}`
})
globalStore.downloadReport = downloadReport as unknown as string | null
onMounted(() => {
customerSiteURL.value = `/reports/sales/customers/${selectedCompany.value?.unique_hash}`
itemsSiteURL.value = `/reports/sales/items/${selectedCompany.value?.unique_hash}`
getInitialReport()
})
function getThisDate(type: string, time: string): string {
return (moment() as Record<string, unknown> as Record<string, (t: string) => moment.Moment>)[type](time).format('YYYY-MM-DD')
}
function getPreDate(type: string, time: string): string {
return (moment().subtract(1, time as moment.unitOfTime.DurationConstructor) as Record<string, unknown> as Record<string, (t: string) => moment.Moment>)[type](time).format('YYYY-MM-DD')
}
function onChangeDateRange(): void {
const key = selectedRange.value.key
switch (key) {
case 'Today':
formData.from_date = moment().format('YYYY-MM-DD')
formData.to_date = moment().format('YYYY-MM-DD')
break
case 'This Week':
formData.from_date = getThisDate('startOf', 'isoWeek')
formData.to_date = getThisDate('endOf', 'isoWeek')
break
case 'This Month':
formData.from_date = getThisDate('startOf', 'month')
formData.to_date = getThisDate('endOf', 'month')
break
case 'This Quarter':
formData.from_date = getThisDate('startOf', 'quarter')
formData.to_date = getThisDate('endOf', 'quarter')
break
case 'This Year':
formData.from_date = getThisDate('startOf', 'year')
formData.to_date = getThisDate('endOf', 'year')
break
case 'Previous Week':
formData.from_date = getPreDate('startOf', 'isoWeek')
formData.to_date = getPreDate('endOf', 'isoWeek')
break
case 'Previous Month':
formData.from_date = getPreDate('startOf', 'month')
formData.to_date = getPreDate('endOf', 'month')
break
case 'Previous Quarter':
formData.from_date = getPreDate('startOf', 'quarter')
formData.to_date = getPreDate('endOf', 'quarter')
break
case 'Previous Year':
formData.from_date = getPreDate('startOf', 'year')
formData.to_date = getPreDate('endOf', 'year')
break
}
}
function getInitialReport(): void {
if (selectedType.value === 'By Customer') {
url.value = customerDateRangeUrl.value
return
}
url.value = itemDateRangeUrl.value
}
function getReports(): boolean {
if (selectedType.value === 'By Customer') {
url.value = customerDateRangeUrl.value
return true
}
url.value = itemDateRangeUrl.value
return true
}
async function viewReportsPDF(): Promise<void> {
getReports()
window.open(getReportUrl.value ?? '', '_blank')
}
function downloadReport(): void {
if (!getReports()) return
window.open(getReportUrl.value + '&download=true')
setTimeout(() => {
if (selectedType.value === 'By Customer') {
url.value = customerDateRangeUrl.value
return
}
url.value = itemDateRangeUrl.value
}, 200)
}
</script>
<template>
<div class="grid gap-8 md:grid-cols-12 pt-10">
<div class="col-span-8 md:col-span-4">
<BaseInputGroup
:label="$t('reports.sales.date_range')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedRange"
:options="dateRange"
value-prop="key"
track-by="key"
label="label"
object
@update:model-value="onChangeDateRange"
/>
</BaseInputGroup>
<div class="flex flex-col my-6 lg:space-x-3 lg:flex-row">
<BaseInputGroup :label="$t('reports.sales.from_date')">
<BaseDatePicker v-model="formData.from_date" />
</BaseInputGroup>
<div
class="hidden w-5 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 2.5rem"
/>
<BaseInputGroup :label="$t('reports.sales.to_date')">
<BaseDatePicker v-model="formData.to_date" />
</BaseInputGroup>
</div>
<BaseInputGroup
:label="$t('reports.sales.report_type')"
class="col-span-12 md:col-span-8"
>
<BaseMultiselect
v-model="selectedType"
:options="reportTypes"
:placeholder="$t('reports.sales.report_type')"
class="mt-1"
@update:model-value="getInitialReport"
/>
</BaseInputGroup>
<BaseButton
variant="primary-outline"
class="content-center hidden mt-0 w-md md:flex md:mt-8"
type="submit"
@click.prevent="getReports"
>
{{ $t('reports.update_report') }}
</BaseButton>
</div>
<div class="col-span-8">
<iframe
:src="getReportUrl ?? undefined"
class="hidden w-full h-screen border-line-light border-solid rounded md:flex"
/>
<a
class="flex items-center justify-center h-10 px-5 py-1 text-sm font-medium leading-none text-center text-white rounded whitespace-nowrap md:hidden bg-primary-500 cursor-pointer"
@click="viewReportsPDF"
>
<BaseIcon name="DocumentTextIcon" class="h-5 mr-2" />
<span>{{ $t('reports.view_pdf') }}</span>
</a>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { useSettingsStore } from './store'
export { default as settingsRoutes } from './routes'

View File

@@ -0,0 +1,81 @@
import type { RouteRecordRaw } from 'vue-router'
const settingsRoutes: RouteRecordRaw[] = [
{
path: 'settings',
component: () => import('./views/SettingsLayoutView.vue'),
children: [
{
path: '',
redirect: 'company-info',
},
{
path: 'account-settings',
name: 'settings.account',
component: () => import('./views/AccountSettingsView.vue'),
},
{
path: 'company-info',
name: 'settings.company-info',
component: () => import('./views/CompanyInfoView.vue'),
},
{
path: 'preferences',
name: 'settings.preferences',
component: () => import('./views/PreferencesView.vue'),
},
{
path: 'customization',
name: 'settings.customization',
component: () => import('./views/CustomizationView.vue'),
},
{
path: 'tax-types',
name: 'settings.tax-types',
component: () => import('./views/TaxTypesView.vue'),
},
{
path: 'payment-modes',
name: 'settings.payment-modes',
component: () => import('./views/PaymentModesView.vue'),
},
{
path: 'custom-fields',
name: 'settings.custom-fields',
component: () => import('./views/CustomFieldsView.vue'),
},
{
path: 'notes',
name: 'settings.notes',
component: () => import('./views/NotesView.vue'),
},
{
path: 'notifications',
name: 'settings.notifications',
component: () => import('./views/NotificationsView.vue'),
},
{
path: 'expense-categories',
name: 'settings.expense-categories',
component: () => import('./views/ExpenseCategoriesView.vue'),
},
{
path: 'exchange-rate',
name: 'settings.exchange-rate',
component: () => import('./views/ExchangeRateView.vue'),
},
{
path: 'mail-config',
name: 'settings.mail-config',
component: () => import('./views/MailConfigView.vue'),
},
{
path: 'roles',
name: 'settings.roles',
component: () => import('./views/RolesView.vue'),
},
],
},
]
export default settingsRoutes

View File

@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { companyService } from '../../../api/services/company.service'
import type { CompanySettingsPayload } from '../../../api/services/company.service'
import { mailService } from '../../../api/services/mail.service'
import type { MailDriver, MailConfigResponse } from '../../../api/services/mail.service'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
/**
* Thin settings store for company mail configuration.
* Most settings views call companyStore.updateCompanySettings() directly;
* this store only manages state for the more complex mail configuration flow.
*/
export const useSettingsStore = defineStore('settings', () => {
// Company Mail state
const mailDrivers = ref<MailDriver[]>([])
const mailConfigData = ref<MailConfigResponse | null>(null)
const currentMailDriver = ref<string>('smtp')
async function fetchMailDrivers(): Promise<MailDriver[]> {
try {
const response = await mailService.getDrivers()
mailDrivers.value = response
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function fetchMailConfig(): Promise<MailConfigResponse> {
try {
const response = await companyService.getMailConfig() as unknown as MailConfigResponse
mailConfigData.value = response
currentMailDriver.value = response.mail_driver ?? 'smtp'
return response
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
async function updateMailConfig(payload: Record<string, unknown>): Promise<void> {
try {
await companyService.saveMailConfig(payload)
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'settings.mail.config_updated',
})
} catch (err: unknown) {
handleApiError(err)
throw err
}
}
return {
mailDrivers,
mailConfigData,
currentMailDriver,
fetchMailDrivers,
fetchMailConfig,
updateMailConfig,
}
})

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, email, sameAs, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useUserStore } from '../../../../stores/user.store'
const userStore = useUserStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const userForm = computed(() => userStore.userForm)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(t('validation.name_min_length'), minLength(3)),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(t('validation.password_min_length'), minLength(8)),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(userForm.value.password)
),
},
}))
const v$ = useVuelidate(rules, userForm)
async function updateAccount(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await userStore.updateCurrentUser({
name: userForm.value.name,
email: userForm.value.email,
password: userForm.value.password || undefined,
confirm_password: userForm.value.confirm_password || undefined,
})
} finally {
isSaving.value = false
}
}
</script>
<template>
<form @submit.prevent="updateAccount">
<BaseSettingCard
:title="$t('settings.account_settings.account_settings')"
:description="$t('settings.account_settings.section_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('settings.account_settings.name')"
:error="v$.name.$error && v$.name.$errors[0]?.$message"
required
>
<BaseInput
v-model="userForm.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.account_settings.email')"
:error="v$.email.$error && v$.email.$errors[0]?.$message"
required
>
<BaseInput
v-model="userForm.email"
type="email"
:invalid="v$.email.$error"
@blur="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.account_settings.password')"
:error="v$.password.$error && v$.password.$errors[0]?.$message"
>
<BaseInput
v-model="userForm.password"
type="password"
:invalid="v$.password.$error"
@blur="v$.password.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.account_settings.confirm_password')"
:error="
v$.confirm_password.$error &&
v$.confirm_password.$errors[0]?.$message
"
>
<BaseInput
v-model="userForm.confirm_password"
type="password"
:invalid="v$.confirm_password.$error"
@blur="v$.confirm_password.$touch()"
/>
</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('settings.account_settings.save') }}
</BaseButton>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useGlobalStore } from '../../../../stores/global.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useModalStore } from '../../../../stores/modal.store'
interface CompanyFormData {
name: string | null
logo: string | null
tax_id: string | null
vat_id: string | null
address: {
address_street_1: string
address_street_2: string
website: string
country_id: number | null
state: string
city: string
phone: string
zip: string
}
}
interface FilePreview {
image: string
}
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const companyForm = reactive<CompanyFormData>({
name: companyStore.selectedCompany?.name ?? null,
logo: companyStore.selectedCompany?.logo ?? null,
tax_id: companyStore.selectedCompany?.tax_id ?? null,
vat_id: companyStore.selectedCompany?.vat_id ?? null,
address: {
address_street_1: (companyStore.selectedCompany?.address as Record<string, string>)?.address_street_1 ?? '',
address_street_2: (companyStore.selectedCompany?.address as Record<string, string>)?.address_street_2 ?? '',
website: (companyStore.selectedCompany?.address as Record<string, string>)?.website ?? '',
country_id: (companyStore.selectedCompany?.address as Record<string, number | null>)?.country_id ?? null,
state: (companyStore.selectedCompany?.address as Record<string, string>)?.state ?? '',
city: (companyStore.selectedCompany?.address as Record<string, string>)?.city ?? '',
phone: (companyStore.selectedCompany?.address as Record<string, string>)?.phone ?? '',
zip: (companyStore.selectedCompany?.address as Record<string, string>)?.zip ?? '',
},
})
const previewLogo = ref<FilePreview[]>([])
const logoFileBlob = ref<string | null>(null)
const logoFileName = ref<string | null>(null)
const isCompanyLogoRemoved = ref<boolean>(false)
if (companyForm.logo) {
previewLogo.value.push({ image: companyForm.logo })
}
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(t('validation.name_min_length'), minLength(3)),
},
address: {
country_id: {
required: helpers.withMessage(t('validation.required'), required),
},
},
}))
const v$ = useVuelidate(
rules,
computed(() => companyForm)
)
globalStore.fetchCountries()
function onFileInputChange(
_fileName: string,
file: string,
_fileCount: number,
fileList: { name: string }
): void {
logoFileName.value = fileList.name
logoFileBlob.value = file
}
function onFileInputRemove(): void {
logoFileBlob.value = null
isCompanyLogoRemoved.value = true
}
async function updateCompanyData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
const res = await companyStore.updateCompany({
name: companyForm.name ?? '',
tax_id: companyForm.tax_id,
vat_id: companyForm.vat_id,
address: companyForm.address,
})
if (res.data) {
if (logoFileBlob.value || isCompanyLogoRemoved.value) {
const logoData = new FormData()
if (logoFileBlob.value) {
logoData.append(
'company_logo',
JSON.stringify({
name: logoFileName.value,
data: logoFileBlob.value,
})
)
}
logoData.append('is_company_logo_removed', String(isCompanyLogoRemoved.value))
await companyStore.updateCompanyLogo(logoData)
logoFileBlob.value = null
isCompanyLogoRemoved.value = false
}
}
isSaving.value = false
}
function removeCompany(): void {
modalStore.openModal({
title: t('settings.company_info.are_you_absolutely_sure'),
componentName: 'DeleteCompanyModal',
size: 'sm',
})
}
</script>
<template>
<form @submit.prevent="updateCompanyData">
<BaseSettingCard
:title="$t('settings.company_info.company_info')"
:description="$t('settings.company_info.section_description')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup :label="$t('settings.company_info.company_logo')">
<BaseFileUploader
v-model="previewLogo"
base64
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('settings.company_info.company_name')"
:error="v$.name.$error && v$.name.$errors[0]?.$message"
required
>
<BaseInput
v-model="companyForm.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.phone')">
<BaseInput v-model="companyForm.address.phone" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.company_info.country')"
:error="
v$.address.country_id.$error &&
v$.address.country_id.$errors[0]?.$message
"
required
>
<BaseMultiselect
v-model="companyForm.address.country_id"
label="name"
:invalid="v$.address.country_id.$error"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.state')">
<BaseInput v-model="companyForm.address.state" name="state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.city')">
<BaseInput v-model="companyForm.address.city" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.zip')">
<BaseInput v-model="companyForm.address.zip" />
</BaseInputGroup>
<div>
<BaseInputGroup :label="$t('settings.company_info.address')">
<BaseTextarea v-model="companyForm.address.address_street_1" rows="2" />
</BaseInputGroup>
<BaseTextarea
v-model="companyForm.address.address_street_2"
rows="2"
class="mt-2"
/>
</div>
<div class="space-y-6">
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model="companyForm.tax_id" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model="companyForm.vat_id" type="text" />
</BaseInputGroup>
</div>
</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('settings.company_info.save') }}
</BaseButton>
<div v-if="companyStore.companies.length !== 1" class="py-5">
<BaseDivider class="my-4" />
<h3 class="text-lg leading-6 font-medium text-heading">
{{ $t('settings.company_info.delete_company') }}
</h3>
<div class="mt-2 max-w-xl text-sm text-muted">
<p>{{ $t('settings.company_info.delete_company_description') }}</p>
</div>
<div class="mt-5">
<button
type="button"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
@click="removeCompany"
>
{{ $t('general.delete') }}
</button>
</div>
</div>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import { customFieldService } from '../../../../api/services/custom-field.service'
import CustomFieldDropdown from '@/scripts/admin/components/dropdowns/CustomFieldIndexDropdown.vue'
import CustomFieldModal from '@/scripts/admin/components/modal-components/custom-fields/CustomFieldModal.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
}
}
const ABILITIES = {
CREATE_CUSTOM_FIELDS: 'create-custom-field',
DELETE_CUSTOM_FIELDS: 'delete-custom-field',
EDIT_CUSTOM_FIELDS: 'edit-custom-field',
} as const
const modalStore = useModalStore()
const userStore = useUserStore()
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const customFieldsColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.custom_fields.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'model_type',
label: t('settings.custom_fields.model'),
},
{
key: 'type',
label: t('settings.custom_fields.type'),
},
{
key: 'is_required',
label: t('settings.custom_fields.required'),
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await customFieldService.list(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
limit: 5,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
},
}
}
function addCustomField(): void {
modalStore.openModal({
title: t('settings.custom_fields.add_custom_field'),
componentName: 'CustomFieldModal',
size: 'sm',
refreshData: table.value?.refresh,
})
}
function refreshTable(): void {
table.value?.refresh()
}
function getModelType(type: string): string {
switch (type) {
case 'Customer':
return t('settings.custom_fields.model_type.customer')
case 'Invoice':
return t('settings.custom_fields.model_type.invoice')
case 'Estimate':
return t('settings.custom_fields.model_type.estimate')
case 'Expense':
return t('settings.custom_fields.model_type.expense')
case 'Payment':
return t('settings.custom_fields.model_type.payment')
default:
return type
}
}
</script>
<template>
<BaseSettingCard
:title="$t('settings.menu_title.custom_fields')"
:description="$t('settings.custom_fields.section_description')"
>
<template #action>
<BaseButton
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOM_FIELDS)"
variant="primary-outline"
@click="addCustomField"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
{{ $t('settings.custom_fields.add_custom_field') }}
</template>
</BaseButton>
</template>
<CustomFieldModal />
<BaseTable
ref="table"
:data="fetchData"
:columns="customFieldsColumns"
class="mt-16"
>
<template #cell-name="{ row }">
{{ row.data.name }}
<span class="text-xs text-muted"> ({{ row.data.slug }})</span>
</template>
<template #cell-model_type="{ row }">
{{ getModelType(row.data.model_type) }}
</template>
<template #cell-is_required="{ row }">
<BaseBadge
:bg-color="row.data.is_required ? 'bg-green-100' : 'bg-gray-100'"
:color="row.data.is_required ? 'text-green-800' : 'text-gray-800'"
>
{{
row.data.is_required
? $t('settings.custom_fields.yes')
: $t('settings.custom_fields.no')
}}
</BaseBadge>
</template>
<template
v-if="
userStore.hasAbilities([
ABILITIES.DELETE_CUSTOM_FIELDS,
ABILITIES.EDIT_CUSTOM_FIELDS,
])
"
#cell-actions="{ row }"
>
<CustomFieldDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>

View File

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

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { exchangeRateService } from '../../../../api/services/exchange-rate.service'
import ExchangeRateProviderModal from '@/scripts/admin/components/modal-components/ExchangeRateProviderModal.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
}
}
const { t } = useI18n()
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const table = ref<{ refresh: () => void } | null>(null)
const drivers = computed<TableColumn[]>(() => [
{
key: 'driver',
label: t('settings.exchange_rate.driver'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'key',
label: t('settings.exchange_rate.key'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'active',
label: t('settings.exchange_rate.active'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await exchangeRateService.listProviders(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
limit: 5,
},
}
}
function addExchangeRate(): void {
modalStore.openModal({
title: t('settings.exchange_rate.new_driver'),
componentName: 'ExchangeRateProviderModal',
size: 'md',
refreshData: table.value?.refresh,
})
}
function editExchangeRate(id: number): void {
exchangeRateService.getProvider(id)
modalStore.openModal({
title: t('settings.exchange_rate.edit_driver'),
componentName: 'ExchangeRateProviderModal',
size: 'md',
data: id,
refreshData: table.value?.refresh,
})
}
function removeExchangeRate(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.exchange_rate.exchange_rate_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await exchangeRateService.deleteProvider(id)
table.value?.refresh()
}
})
}
</script>
<template>
<ExchangeRateProviderModal />
<BaseCard>
<template #header>
<div class="flex flex-wrap justify-between lg:flex-nowrap">
<div>
<h6 class="text-lg font-medium text-left">
{{ $t('settings.menu_title.exchange_rate') }}
</h6>
<p
class="mt-2 text-sm leading-snug text-left text-muted"
style="max-width: 680px"
>
{{ $t('settings.exchange_rate.providers_description') }}
</p>
</div>
<div class="mt-4 lg:mt-0 lg:ml-2">
<BaseButton
variant="primary-outline"
size="lg"
@click="addExchangeRate"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('settings.exchange_rate.new_driver') }}
</BaseButton>
</div>
</div>
</template>
<BaseTable ref="table" class="mt-16" :data="fetchData" :columns="drivers">
<template #cell-driver="{ row }">
<span class="capitalize">{{ row.data.driver.replace('_', ' ') }}</span>
</template>
<template #cell-active="{ row }">
<BaseBadge
:bg-color="row.data.active ? 'bg-green-100' : 'bg-gray-100'"
:color="row.data.active ? 'text-green-800' : 'text-gray-800'"
>
{{ row.data.active ? 'YES' : 'NO' }}
</BaseBadge>
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<div class="inline-block">
<BaseIcon name="EllipsisHorizontalIcon" class="w-5 text-muted" />
</div>
</template>
<BaseDropdownItem @click="editExchangeRate(row.data.id)">
<BaseIcon name="PencilIcon" class="h-5 mr-3 text-body" />
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removeExchangeRate(row.data.id)">
<BaseIcon name="TrashIcon" class="h-5 mr-3 text-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</BaseCard>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { expenseService } from '../../../../api/services/expense.service'
import ExpenseCategoryDropdown from '@/scripts/admin/components/dropdowns/ExpenseCategoryIndexDropdown.vue'
import CategoryModal from '@/scripts/admin/components/modal-components/CategoryModal.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
}
}
const modalStore = useModalStore()
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const expenseCategoryColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.expense_category.category_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'description',
label: t('settings.expense_category.category_description'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await expenseService.listCategories(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
limit: 5,
},
}
}
function openCategoryModal(): void {
modalStore.openModal({
title: t('settings.expense_category.add_category'),
componentName: 'CategoryModal',
size: 'sm',
refreshData: table.value?.refresh,
})
}
function refreshTable(): void {
table.value?.refresh()
}
</script>
<template>
<CategoryModal />
<BaseSettingCard
:title="$t('settings.expense_category.title')"
:description="$t('settings.expense_category.description')"
>
<template #action>
<BaseButton
variant="primary-outline"
type="button"
@click="openCategoryModal"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.expense_category.add_new_category') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="expenseCategoryColumns"
class="mt-16"
>
<template #cell-description="{ row }">
<div class="w-64">
<p class="truncate">{{ row.data.description }}</p>
</div>
</template>
<template #cell-actions="{ row }">
<ExpenseCategoryDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { companyService } from '../../../../api/services/company.service'
import { mailService } from '../../../../api/services/mail.service'
import type { MailDriver } from '../../../../api/services/mail.service'
import Smtp from '@/scripts/admin/views/settings/mail-driver/SmtpMailDriver.vue'
import Mailgun from '@/scripts/admin/views/settings/mail-driver/MailgunMailDriver.vue'
import Ses from '@/scripts/admin/views/settings/mail-driver/SesMailDriver.vue'
import Basic from '@/scripts/admin/views/settings/mail-driver/BasicMailDriver.vue'
import MailTestModal from '@/scripts/admin/components/modal-components/MailTestModal.vue'
const { t } = useI18n()
const modalStore = useModalStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const useCustomMailConfig = ref<boolean>(false)
const mailConfigData = ref<Record<string, unknown> | null>(null)
const mailDrivers = ref<MailDriver[]>([])
const currentMailDriver = ref<string>('smtp')
loadData()
async function loadData(): Promise<void> {
isFetchingInitialData.value = true
const [driversResponse, configResponse] = await Promise.all([
mailService.getDrivers(),
companyService.getMailConfig(),
])
mailDrivers.value = driversResponse
mailConfigData.value = configResponse
currentMailDriver.value = (configResponse.mail_driver as string) ?? 'smtp'
useCustomMailConfig.value =
(configResponse.use_custom_mail_config as string) === 'YES'
isFetchingInitialData.value = false
}
function changeDriver(value: string): void {
currentMailDriver.value = value
if (mailConfigData.value) {
mailConfigData.value.mail_driver = value
}
}
const mailDriver = computed(() => {
if (currentMailDriver.value === 'smtp') return Smtp
if (currentMailDriver.value === 'mailgun') return Mailgun
if (currentMailDriver.value === 'sendmail') return Basic
if (currentMailDriver.value === 'ses') return Ses
if (currentMailDriver.value === 'mail') return Basic
return Smtp
})
watch(useCustomMailConfig, async (newVal, oldVal) => {
if (oldVal === undefined) return
if (!newVal) {
isSaving.value = true
await companyService.saveMailConfig({
use_custom_mail_config: 'NO',
mail_driver: '',
})
isSaving.value = false
}
})
async function saveEmailConfig(value: Record<string, unknown>): Promise<void> {
try {
isSaving.value = true
await companyService.saveMailConfig({
...value,
use_custom_mail_config: 'YES',
})
} finally {
isSaving.value = false
}
}
function openMailTestModal(): void {
modalStore.openModal({
title: t('general.test_mail_conf'),
componentName: 'MailTestModal',
size: 'sm',
})
}
</script>
<template>
<MailTestModal :store-type="'company'" />
<BaseSettingCard
:title="$t('settings.mail.company_mail_config')"
:description="$t('settings.mail.company_mail_config_desc')"
>
<div class="mt-8">
<BaseSwitchSection
v-model="useCustomMailConfig"
:title="$t('settings.mail.use_custom_mail_config')"
:description="$t('settings.mail.use_custom_mail_config_desc')"
/>
</div>
<div v-if="useCustomMailConfig && mailConfigData" class="mt-8">
<component
:is="mailDriver"
:config-data="mailConfigData"
:is-saving="isSaving"
:mail-drivers="mailDrivers"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="(val: string) => changeDriver(val)"
@submit-data="saveEmailConfig"
>
<BaseButton
variant="primary-outline"
type="button"
class="ml-2"
:content-loading="isFetchingInitialData"
@click="openMailTestModal"
>
{{ $t('general.test_mail_conf') }}
</BaseButton>
</component>
</div>
<div
v-if="!useCustomMailConfig"
class="mt-4 p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-sm text-status-green flex items-center"
>
<BaseIcon name="CheckCircleIcon" class="w-5 h-5 mr-2 shrink-0" />
{{ $t('settings.mail.using_global_mail_config') }}
</div>
</BaseSettingCard>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import { noteService } from '../../../../api/services/note.service'
import NoteDropdown from '@/scripts/admin/components/dropdowns/NoteIndexDropdown.vue'
import NoteModal from '@/scripts/admin/components/modal-components/NoteModal.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
}
}
const ABILITIES = {
MANAGE_NOTE: 'manage-note',
} as const
const { t } = useI18n()
const modalStore = useModalStore()
const userStore = useUserStore()
const table = ref<{ refresh: () => void } | null>(null)
const notesColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.customization.notes.name'),
thClass: 'extra',
tdClass: 'font-medium text-heading flex gap-1 items-center',
},
{
key: 'type',
label: t('settings.customization.notes.type'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await noteService.list(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
limit: 5,
},
}
}
function openNoteSelectModal(): void {
modalStore.openModal({
title: t('settings.customization.notes.add_note'),
componentName: 'NoteModal',
size: 'md',
refreshData: table.value?.refresh,
})
}
function refreshTable(): void {
table.value?.refresh()
}
function getLabelNote(type: string): string {
switch (type) {
case 'Estimate':
return t('settings.customization.notes.types.estimate')
case 'Invoice':
return t('settings.customization.notes.types.invoice')
case 'Payment':
return t('settings.customization.notes.types.payment')
default:
return type
}
}
</script>
<template>
<NoteModal />
<BaseSettingCard
:title="$t('settings.customization.notes.title')"
:description="$t('settings.customization.notes.description')"
>
<template #action>
<BaseButton
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
variant="primary-outline"
@click="openNoteSelectModal"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.customization.notes.add_note') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="notesColumns"
class="mt-14"
>
<template #cell-actions="{ row }">
<NoteDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
<template #cell-name="{ row }">
{{ row.data.name }}
<BaseIcon
v-if="row.data.is_default"
name="StarIcon"
class="w-3 h-3 text-primary-400"
/>
</template>
<template #cell-type="{ row }">
{{ getLabelNote(row.data.type) }}
</template>
</BaseTable>
</BaseSettingCard>
</template>

View File

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

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { paymentService } from '../../../../api/services/payment.service'
import PaymentModeModal from '@/scripts/admin/components/modal-components/PaymentModeModal.vue'
import PaymentModeDropdown from '@/scripts/admin/components/dropdowns/PaymentModeIndexDropdown.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
}
}
const modalStore = useModalStore()
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const paymentColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.payment_modes.mode_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function refreshTable(): Promise<void> {
table.value?.refresh()
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await paymentService.listMethods(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>)?.last_page ?? 1,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>)?.total ?? 0,
limit: 5,
},
}
}
function addPaymentMode(): void {
modalStore.openModal({
title: t('settings.payment_modes.add_payment_mode'),
componentName: 'PaymentModeModal',
refreshData: table.value?.refresh,
size: 'sm',
})
}
</script>
<template>
<PaymentModeModal />
<BaseSettingCard
:title="$t('settings.payment_modes.title')"
:description="$t('settings.payment_modes.description')"
>
<template #action>
<BaseButton
type="submit"
variant="primary-outline"
@click="addPaymentMode"
>
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.payment_modes.add_payment_mode') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="paymentColumns"
class="mt-16"
>
<template #cell-actions="{ row }">
<PaymentModeDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>

View File

@@ -0,0 +1,373 @@
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useGlobalStore } from '../../../../stores/global.store'
import { useCompanyStore } from '../../../../stores/company.store'
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isDataSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const settingsForm = reactive<Record<string, string>>({
...companyStore.selectedCompanySettings,
})
const fiscalYearsList = computed(() => {
const config = globalStore.config as Record<string, unknown> | null
const fiscalYears = (config?.fiscal_years ?? []) as Array<{ key: string; value: string }>
return fiscalYears.map((item) => ({
...item,
key: t(item.key),
}))
})
watch(
() => settingsForm.carbon_date_format,
(val) => {
if (val) {
const dateFormatObject = globalStore.dateFormats.find(
(d) => d.carbon_format_value === val
)
if (dateFormatObject) {
settingsForm.moment_date_format = dateFormatObject.moment_format_value
}
}
}
)
watch(
() => settingsForm.carbon_time_format,
(val) => {
if (val) {
const timeFormatObject = globalStore.timeFormats.find(
(d) => d.carbon_format_value === val
)
if (timeFormatObject) {
settingsForm.moment_time_format = timeFormatObject.moment_format_value
}
}
}
)
const invoiceUseTimeField = computed<boolean>({
get: () => settingsForm.invoice_use_time === 'YES',
set: (newValue: boolean) => {
settingsForm.invoice_use_time = newValue ? 'YES' : 'NO'
},
})
const discountPerItemField = computed<boolean>({
get: () => settingsForm.discount_per_item === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
settingsForm.discount_per_item = value
await companyStore.updateCompanySettings({
data: { settings: { discount_per_item: value } },
message: 'general.setting_updated',
})
},
})
const expirePdfField = computed<boolean>({
get: () => settingsForm.automatically_expire_public_links === 'YES',
set: (newValue: boolean) => {
settingsForm.automatically_expire_public_links = newValue ? 'YES' : 'NO'
},
})
const rules = computed(() => ({
currency: {
required: helpers.withMessage(t('validation.required'), required),
},
language: {
required: helpers.withMessage(t('validation.required'), required),
},
carbon_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
moment_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
carbon_time_format: {
required: helpers.withMessage(t('validation.required'), required),
},
moment_time_format: {
required: helpers.withMessage(t('validation.required'), required),
},
time_zone: {
required: helpers.withMessage(t('validation.required'), required),
},
fiscal_year: {
required: helpers.withMessage(t('validation.required'), required),
},
invoice_use_time: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(
rules,
computed(() => settingsForm)
)
setInitialData()
async function setInitialData(): Promise<void> {
isFetchingInitialData.value = true
await Promise.all([
globalStore.fetchCurrencies(),
globalStore.fetchDateFormats(),
globalStore.fetchTimeFormats(),
globalStore.fetchTimeZones(),
])
isFetchingInitialData.value = false
}
async function updatePreferencesData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
const data = {
settings: { ...settingsForm } as Record<string, string>,
}
delete data.settings.link_expiry_days
if (companyStore.selectedCompanySettings.language !== settingsForm.language) {
const win = window as Record<string, unknown>
if (typeof win.loadLanguage === 'function') {
await (win.loadLanguage as (lang: string) => Promise<void>)(settingsForm.language)
}
}
await companyStore.updateCompanySettings({
data,
message: 'settings.preferences.updated_message',
})
isSaving.value = false
}
async function submitData(): Promise<void> {
isDataSaving.value = true
await companyStore.updateCompanySettings({
data: {
settings: {
link_expiry_days: settingsForm.link_expiry_days,
automatically_expire_public_links:
settingsForm.automatically_expire_public_links,
},
},
message: 'settings.preferences.updated_message',
})
isDataSaving.value = false
}
</script>
<template>
<form action="" class="relative" @submit.prevent="updatePreferencesData">
<BaseSettingCard
:title="$t('settings.menu_title.preferences')"
:description="$t('settings.preferences.general_settings')"
>
<BaseInputGrid class="mt-5">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.preferences.currency')"
:help-text="$t('settings.preferences.company_currency_unchangeable')"
:error="v$.currency.$error && v$.currency.$errors[0]?.$message"
required
>
<BaseMultiselect
v-model="settingsForm.currency"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
label="name"
value-prop="id"
:searchable="true"
track-by="name"
:invalid="v$.currency.$error"
disabled
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.default_language')"
:content-loading="isFetchingInitialData"
:error="v$.language.$error && v$.language.$errors[0]?.$message"
required
>
<BaseMultiselect
v-model="settingsForm.language"
:content-loading="isFetchingInitialData"
:options="(globalStore.config as Record<string, unknown>)?.languages as Array<{ code: string; name: string }> ?? []"
label="name"
value-prop="code"
class="w-full"
track-by="name"
:searchable="true"
:invalid="v$.language.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.time_zone')"
:content-loading="isFetchingInitialData"
:error="v$.time_zone.$error && v$.time_zone.$errors[0]?.$message"
required
>
<BaseMultiselect
v-model="settingsForm.time_zone"
:content-loading="isFetchingInitialData"
:options="globalStore.timeZones"
label="key"
value-prop="value"
track-by="key"
:searchable="true"
:invalid="v$.time_zone.$error"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.date_format')"
:content-loading="isFetchingInitialData"
:error="
v$.carbon_date_format.$error &&
v$.carbon_date_format.$errors[0]?.$message
"
required
>
<BaseMultiselect
v-model="settingsForm.carbon_date_format"
:content-loading="isFetchingInitialData"
:options="globalStore.dateFormats"
label="display_date"
value-prop="carbon_format_value"
track-by="display_date"
:searchable="true"
:invalid="v$.carbon_date_format.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:error="v$.fiscal_year.$error && v$.fiscal_year.$errors[0]?.$message"
:label="$t('settings.preferences.fiscal_year')"
required
>
<BaseMultiselect
v-model="settingsForm.fiscal_year"
:content-loading="isFetchingInitialData"
:options="fiscalYearsList"
label="key"
value-prop="value"
:invalid="v$.fiscal_year.$error"
track-by="key"
:searchable="true"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.time_format')"
:content-loading="isFetchingInitialData"
:error="
v$.carbon_time_format.$error &&
v$.carbon_time_format.$errors[0]?.$message
"
required
>
<BaseMultiselect
v-model="settingsForm.carbon_time_format"
:content-loading="isFetchingInitialData"
:options="globalStore.timeFormats"
label="display_time"
value-prop="carbon_format_value"
track-by="display_time"
:searchable="true"
:invalid="v$.carbon_time_format.$error"
class="w-full"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseSwitchSection
v-model="invoiceUseTimeField"
:title="$t('settings.preferences.invoice_use_time')"
:description="$t('settings.preferences.invoice_use_time_description')"
/>
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isSaving"
:loading="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('settings.company_info.save') }}
</BaseButton>
<BaseDivider class="mt-6 mb-2" />
<ul>
<form @submit.prevent="submitData">
<BaseSwitchSection
v-model="expirePdfField"
:title="$t('settings.preferences.expire_public_links')"
:description="$t('settings.preferences.expire_setting_description')"
/>
<BaseInputGroup
v-if="expirePdfField"
:content-loading="isFetchingInitialData"
:label="$t('settings.preferences.expire_public_links')"
class="mt-2 mb-4"
>
<BaseInput
v-model="settingsForm.link_expiry_days"
:disabled="settingsForm.automatically_expire_public_links === 'NO'"
:content-loading="isFetchingInitialData"
type="number"
/>
</BaseInputGroup>
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isDataSaving"
:loading="isDataSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</form>
<BaseDivider class="mt-6 mb-2" />
<BaseSwitchSection
v-model="discountPerItemField"
:title="$t('settings.preferences.discount_per_item')"
:description="$t('settings.preferences.discount_setting_description')"
/>
</ul>
</BaseSettingCard>
</form>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { roleService } from '../../../../api/services/role.service'
import RoleDropdown from '@/scripts/admin/components/dropdowns/RoleIndexDropdown.vue'
import RolesModal from '@/scripts/admin/components/modal-components/RolesModal.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[]
}
const modalStore = useModalStore()
const userStore = useUserStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const roleColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.roles.role_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'created_at',
label: t('settings.roles.added_on'),
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
company_id: companyStore.selectedCompany?.id,
}
const response = await roleService.list(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
}
}
function refreshTable(): void {
table.value?.refresh()
}
async function openRoleModal(): Promise<void> {
await roleService.getAbilities()
modalStore.openModal({
title: t('settings.roles.add_role'),
componentName: 'RolesModal',
size: 'lg',
refreshData: table.value?.refresh,
})
}
</script>
<template>
<RolesModal />
<BaseSettingCard
:title="$t('settings.roles.title')"
:description="$t('settings.roles.description')"
>
<template v-if="userStore.currentUser?.is_owner" #action>
<BaseButton variant="primary-outline" @click="openRoleModal">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('settings.roles.add_new_role') }}
</BaseButton>
</template>
<BaseTable
ref="table"
:data="fetchData"
:columns="roleColumns"
class="mt-14"
>
<template #cell-created_at="{ row }">
{{ row.data.formatted_created_at }}
</template>
<template #cell-actions="{ row }">
<RoleDropdown
v-if="
userStore.currentUser?.is_owner && row.data.name !== 'super admin'
"
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</BaseSettingCard>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '../../../../stores/global.store'
interface SettingMenuItem {
title: string
link: string
icon: string
}
interface DropdownMenuItem extends SettingMenuItem {
title: string
}
const { t } = useI18n()
const globalStore = useGlobalStore()
const route = useRoute()
const router = useRouter()
const currentSetting = ref<DropdownMenuItem | undefined>(undefined)
const dropdownMenuItems = computed<DropdownMenuItem[]>(() => {
return (globalStore.settingMenu as SettingMenuItem[]).map((item) => ({
...item,
title: t(item.title),
}))
})
watchEffect(() => {
if (route.path === '/admin/settings') {
router.push('/admin/settings/company-info')
}
const item = dropdownMenuItems.value.find((item) => item.link === route.path)
currentSetting.value = item
})
function hasActiveUrl(url: string): boolean {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting: DropdownMenuItem): void {
router.push(setting.link)
}
</script>
<template>
<BasePage>
<BasePageHeader :title="$t('settings.setting', 1)" class="mb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('settings.setting', 2)"
to="/admin/settings/company-info"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="dropdownMenuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update: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 globalStore.settingMenu"
:key="index"
:title="$t(menuItem.title)"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<BaseIcon :name="menuItem.icon" />
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import { useModalStore } from '../../../../stores/modal.store'
import { taxTypeService } from '../../../../api/services/tax-type.service'
import TaxTypeDropdown from '@/scripts/admin/components/dropdowns/TaxTypeIndexDropdown.vue'
import TaxTypeModal from '@/scripts/admin/components/modal-components/TaxTypeModal.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
}
}
const ABILITIES = {
CREATE_TAX_TYPE: 'create-tax-type',
DELETE_TAX_TYPE: 'delete-tax-type',
EDIT_TAX_TYPE: 'edit-tax-type',
} as const
const { t } = useI18n()
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const userStore = useUserStore()
const table = ref<{ refresh: () => void } | null>(null)
const taxPerItemSetting = ref<string>(companyStore.selectedCompanySettings.tax_per_item)
const defaultCurrency = computed(() => companyStore.selectedCompanyCurrency)
const taxTypeColumns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.tax_types.tax_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'calculation_type',
label: t('settings.tax_types.calculation_type'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'amount',
label: t('settings.tax_types.amount'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
const salesTaxEnabled = computed<boolean>(() => {
return companyStore.selectedCompanySettings.sales_tax_us_enabled === 'YES'
})
const taxPerItemField = computed<boolean>({
get: () => taxPerItemSetting.value === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
taxPerItemSetting.value = value
await companyStore.updateCompanySettings({
data: { settings: { tax_per_item: value } },
message: 'general.setting_updated',
})
},
})
const taxIncludedSettings = reactive<{ tax_included: string; tax_included_by_default: string }>({
tax_included: companyStore.selectedCompanySettings.tax_included ?? 'NO',
tax_included_by_default: companyStore.selectedCompanySettings.tax_included_by_default ?? 'NO',
})
const taxIncludedField = computed<boolean>({
get: () => taxIncludedSettings.tax_included === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
taxIncludedSettings.tax_included = value
if (!newValue) {
taxIncludedSettings.tax_included_by_default = 'NO'
}
await companyStore.updateCompanySettings({
data: { settings: { ...taxIncludedSettings } },
message: 'general.setting_updated',
})
},
})
const taxIncludedByDefaultField = computed<boolean>({
get: () => taxIncludedSettings.tax_included_by_default === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
taxIncludedSettings.tax_included_by_default = value
await companyStore.updateCompanySettings({
data: { settings: { tax_included_by_default: value } },
message: 'general.setting_updated',
})
},
})
function hasAtleastOneAbility(): boolean {
return userStore.hasAbilities([ABILITIES.DELETE_TAX_TYPE, ABILITIES.EDIT_TAX_TYPE])
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await taxTypeService.list(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
limit: 5,
},
}
}
function refreshTable(): void {
table.value?.refresh()
}
function openTaxModal(): void {
modalStore.openModal({
title: t('settings.tax_types.add_tax'),
componentName: 'TaxTypeModal',
size: 'sm',
refreshData: table.value?.refresh,
})
}
</script>
<template>
<BaseSettingCard
:title="$t('settings.tax_types.title')"
:description="$t('settings.tax_types.description')"
>
<TaxTypeModal />
<template v-if="userStore.hasAbilities(ABILITIES.CREATE_TAX_TYPE)" #action>
<BaseButton type="submit" variant="primary-outline" @click="openTaxModal">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.tax_types.add_new_tax') }}
</BaseButton>
</template>
<BaseTable
ref="table"
class="mt-16"
:data="fetchData"
:columns="taxTypeColumns"
>
<template #cell-calculation_type="{ row }">
{{ $t(`settings.tax_types.${row.data.calculation_type}`) }}
</template>
<template #cell-amount="{ row }">
<template v-if="row.data.calculation_type === 'percentage'">
{{ row.data.percent }} %
</template>
<template v-else-if="row.data.calculation_type === 'fixed'">
<BaseFormatMoney :amount="row.data.fixed_amount" :currency="defaultCurrency" />
</template>
<template v-else> - </template>
</template>
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<TaxTypeDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
<div v-if="userStore.currentUser?.is_owner">
<BaseDivider class="mt-8 mb-2" />
<BaseSwitchSection
v-model="taxPerItemField"
:disabled="salesTaxEnabled"
:title="$t('settings.tax_types.tax_per_item')"
:description="$t('settings.tax_types.tax_setting_description')"
/>
<BaseDivider class="mt-8 mb-2" />
<BaseSwitchSection
v-model="taxIncludedField"
:title="$t('settings.tax_types.tax_included')"
:description="$t('settings.tax_types.tax_included_description')"
/>
<BaseSwitchSection
v-if="taxIncludedField"
v-model="taxIncludedByDefaultField"
:title="$t('settings.tax_types.tax_included_by_default')"
:description="$t('settings.tax_types.tax_included_by_default_description')"
/>
</div>
</BaseSettingCard>
</template>

View File

@@ -0,0 +1,26 @@
<template>
<div v-if="isAppLoaded" class="h-full">
<slot name="header" />
<main class="mt-16 pb-16 h-screen overflow-y-auto min-h-0">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useCustomerPortalStore } from '../store'
const store = useCustomerPortalStore()
const route = useRoute()
const isAppLoaded = computed<boolean>(() => store.isAppLoaded)
onMounted(async () => {
const companySlug = route.params.company as string
if (companySlug && !store.isAppLoaded) {
await store.bootstrap(companySlug)
}
})
</script>

View File

@@ -0,0 +1,27 @@
export { customerPortalRoutes } from './routes'
export { useCustomerPortalStore } from './store'
export type {
CustomerPortalState,
CustomerPortalStore,
CustomerPortalMenuItem,
CustomerUserForm,
CustomerAddress,
CustomerLoginData,
DashboardData,
PaginatedListParams,
PaginatedResponse,
} from './store'
// Views
export { default as CustomerDashboardView } from './views/CustomerDashboardView.vue'
export { default as CustomerInvoicesView } from './views/CustomerInvoicesView.vue'
export { default as CustomerInvoiceDetailView } from './views/CustomerInvoiceDetailView.vue'
export { default as CustomerEstimatesView } from './views/CustomerEstimatesView.vue'
export { default as CustomerEstimateDetailView } from './views/CustomerEstimateDetailView.vue'
export { default as CustomerPaymentsView } from './views/CustomerPaymentsView.vue'
export { default as CustomerPaymentDetailView } from './views/CustomerPaymentDetailView.vue'
export { default as CustomerSettingsView } from './views/CustomerSettingsView.vue'
// Components
export { default as CustomerPortalLayout } from './components/CustomerPortalLayout.vue'

View File

@@ -0,0 +1,61 @@
import type { RouteRecordRaw } from 'vue-router'
const CustomerPortalLayout = () => import('./components/CustomerPortalLayout.vue')
const CustomerDashboardView = () => import('./views/CustomerDashboardView.vue')
const CustomerInvoicesView = () => import('./views/CustomerInvoicesView.vue')
const CustomerInvoiceDetailView = () => import('./views/CustomerInvoiceDetailView.vue')
const CustomerEstimatesView = () => import('./views/CustomerEstimatesView.vue')
const CustomerEstimateDetailView = () => import('./views/CustomerEstimateDetailView.vue')
const CustomerPaymentsView = () => import('./views/CustomerPaymentsView.vue')
const CustomerPaymentDetailView = () => import('./views/CustomerPaymentDetailView.vue')
const CustomerSettingsView = () => import('./views/CustomerSettingsView.vue')
export const customerPortalRoutes: RouteRecordRaw[] = [
{
path: '/:company/customer',
component: CustomerPortalLayout,
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'customer-portal.dashboard',
component: CustomerDashboardView,
},
{
path: 'invoices',
name: 'customer-portal.invoices',
component: CustomerInvoicesView,
},
{
path: 'invoices/:id/view',
name: 'customer-portal.invoices.view',
component: CustomerInvoiceDetailView,
},
{
path: 'estimates',
name: 'customer-portal.estimates',
component: CustomerEstimatesView,
},
{
path: 'estimates/:id/view',
name: 'customer-portal.estimates.view',
component: CustomerEstimateDetailView,
},
{
path: 'payments',
name: 'customer-portal.payments',
component: CustomerPaymentsView,
},
{
path: 'payments/:id/view',
name: 'customer-portal.payments.view',
component: CustomerPaymentDetailView,
},
{
path: 'settings',
name: 'customer-portal.settings',
component: CustomerSettingsView,
},
],
},
]

View File

@@ -0,0 +1,432 @@
import { defineStore } from 'pinia'
import { client } from '../../api/client'
import type { Invoice } from '../../types/domain/invoice'
import type { Estimate, EstimateStatus } from '../../types/domain/estimate'
import type { Payment, PaymentMethod } from '../../types/domain/payment'
import type { Currency } from '../../types/domain/currency'
import type { Customer, Country } from '../../types/domain/customer'
// ----------------------------------------------------------------
// Types
// ----------------------------------------------------------------
export interface CustomerPortalMenuItem {
title: string
link: string
icon?: string
name?: string
}
export interface CustomerUserForm {
avatar: string | null
name: string
email: string
password: string
confirm_password: string
company: string
billing: CustomerAddress
shipping: CustomerAddress
}
export interface CustomerAddress {
name: 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
phone: string | null
type: string
}
export interface DashboardData {
recentInvoices: Invoice[]
recentEstimates: Estimate[]
invoiceCount: number
estimateCount: number
paymentCount: number
totalDueAmount: number
}
export interface CustomerLoginData {
email: string
password: string
device_name: string
company: string
}
export interface PaginatedListParams {
page?: number
limit?: number | string
orderByField?: string
orderBy?: string
[key: string]: unknown
}
export interface PaginatedResponse<T> {
data: T[]
meta: {
last_page: number
total: number
[key: string]: unknown
}
}
// ----------------------------------------------------------------
// Address stub
// ----------------------------------------------------------------
function createAddressStub(type: string = 'billing'): CustomerAddress {
return {
name: null,
address_street_1: null,
address_street_2: null,
city: null,
state: null,
country_id: null,
zip: null,
phone: null,
type,
}
}
function createUserFormStub(): CustomerUserForm {
return {
avatar: null,
name: '',
email: '',
password: '',
confirm_password: '',
company: '',
billing: createAddressStub('billing'),
shipping: createAddressStub('shipping'),
}
}
// ----------------------------------------------------------------
// Helper to build customer API base URL
// ----------------------------------------------------------------
function customerApi(slug: string, path: string = ''): string {
return `/api/v1/${slug}/customer${path}`
}
// ----------------------------------------------------------------
// Store
// ----------------------------------------------------------------
export interface CustomerPortalState {
// Global
companySlug: string
isAppLoaded: boolean
currency: Currency | null
countries: Country[]
currentUser: Customer | null
mainMenu: CustomerPortalMenuItem[]
enabledModules: string[]
getDashboardDataLoaded: boolean
// User form
userForm: CustomerUserForm
// Dashboard
recentInvoices: Invoice[]
recentEstimates: Estimate[]
invoiceCount: number
estimateCount: number
paymentCount: number
totalDueAmount: number
// Invoices
invoices: Invoice[]
totalInvoices: number
selectedViewInvoice: Invoice | null
// Estimates
estimates: Estimate[]
totalEstimates: number
selectedViewEstimate: Estimate | null
// Payments
payments: Payment[]
totalPayments: number
selectedViewPayment: Payment | null
// Auth
loginData: CustomerLoginData
}
export const useCustomerPortalStore = defineStore('customerPortal', {
state: (): CustomerPortalState => ({
companySlug: '',
isAppLoaded: false,
currency: null,
countries: [],
currentUser: null,
mainMenu: [],
enabledModules: [],
getDashboardDataLoaded: false,
userForm: createUserFormStub(),
recentInvoices: [],
recentEstimates: [],
invoiceCount: 0,
estimateCount: 0,
paymentCount: 0,
totalDueAmount: 0,
invoices: [],
totalInvoices: 0,
selectedViewInvoice: null,
estimates: [],
totalEstimates: 0,
selectedViewEstimate: null,
payments: [],
totalPayments: 0,
selectedViewPayment: null,
loginData: {
email: '',
password: '',
device_name: 'xyz',
company: '',
},
}),
actions: {
// ---- Bootstrap ----
async bootstrap(slug: string): Promise<void> {
this.companySlug = slug
const { data } = await client.get(customerApi(slug, '/bootstrap'))
this.currentUser = data.data
this.mainMenu = data.meta.menu ?? []
this.currency = data.data.currency ?? null
this.enabledModules = data.meta.modules ?? []
Object.assign(this.userForm, data.data)
this.isAppLoaded = true
},
async fetchCountries(): Promise<Country[]> {
if (this.countries.length) return this.countries
const { data } = await client.get(
customerApi(this.companySlug, '/countries'),
)
this.countries = data.data
return this.countries
},
// ---- Dashboard ----
async loadDashboard(): Promise<void> {
const { data } = await client.get(
customerApi(this.companySlug, '/dashboard'),
)
this.totalDueAmount = data.due_amount
this.estimateCount = data.estimate_count
this.invoiceCount = data.invoice_count
this.paymentCount = data.payment_count
this.recentInvoices = data.recentInvoices
this.recentEstimates = data.recentEstimates
this.getDashboardDataLoaded = true
},
// ---- Invoices ----
async fetchInvoices(
params: PaginatedListParams,
): Promise<{ data: PaginatedResponse<Invoice> }> {
const { data } = await client.get(
customerApi(this.companySlug, '/invoices'),
{ params },
)
this.invoices = data.data
if (data.meta?.invoiceTotalCount !== undefined) {
this.totalInvoices = data.meta.invoiceTotalCount
}
return { data }
},
async fetchViewInvoice(id: number | string): Promise<{ data: { data: Invoice } }> {
const { data } = await client.get(
customerApi(this.companySlug, `/invoices/${id}`),
)
this.selectedViewInvoice = data.data
return { data }
},
async searchInvoices(
params: PaginatedListParams,
): Promise<Invoice[]> {
const { data } = await client.get(
customerApi(this.companySlug, '/invoices'),
{ params },
)
this.invoices = data.data ?? data
return this.invoices
},
// ---- Estimates ----
async fetchEstimates(
params: PaginatedListParams,
): Promise<{ data: PaginatedResponse<Estimate> }> {
const { data } = await client.get(
customerApi(this.companySlug, '/estimates'),
{ params },
)
this.estimates = data.data
if (data.meta?.estimateTotalCount !== undefined) {
this.totalEstimates = data.meta.estimateTotalCount
}
return { data }
},
async fetchViewEstimate(id: number | string): Promise<{ data: { data: Estimate } }> {
const { data } = await client.get(
customerApi(this.companySlug, `/estimates/${id}`),
)
this.selectedViewEstimate = data.data
return { data }
},
async searchEstimates(
params: PaginatedListParams,
): Promise<Estimate[]> {
const { data } = await client.get(
customerApi(this.companySlug, '/estimates'),
{ params },
)
this.estimates = data.data ?? data
return this.estimates
},
async updateEstimateStatus(
id: number | string,
status: EstimateStatus,
): Promise<void> {
await client.post(
customerApi(this.companySlug, `/estimate/${id}/status`),
{ status },
)
const pos = this.estimates.findIndex((e) => e.id === Number(id))
if (pos !== -1) {
this.estimates[pos].status = status
}
},
// ---- Payments ----
async fetchPayments(
params: PaginatedListParams,
): Promise<{ data: PaginatedResponse<Payment> }> {
const { data } = await client.get(
customerApi(this.companySlug, '/payments'),
{ params },
)
this.payments = data.data
if (data.meta?.paymentTotalCount !== undefined) {
this.totalPayments = data.meta.paymentTotalCount
}
return { data }
},
async fetchViewPayment(id: number | string): Promise<{ data: { data: Payment } }> {
const { data } = await client.get(
customerApi(this.companySlug, `/payments/${id}`),
)
this.selectedViewPayment = data.data
return { data }
},
async searchPayments(
params: PaginatedListParams,
): Promise<Payment[]> {
const { data } = await client.get(
customerApi(this.companySlug, '/payments'),
{ params },
)
this.payments = data.data ?? data
return this.payments
},
async fetchPaymentModes(search: string): Promise<PaymentMethod[]> {
const { data } = await client.get(
customerApi(this.companySlug, '/payment-method'),
{ params: search ? { search } : {} },
)
return data.data
},
// ---- User / Settings ----
async fetchCurrentUser(): Promise<void> {
const { data } = await client.get(
customerApi(this.companySlug, '/me'),
)
Object.assign(this.userForm, data.data)
},
async updateCurrentUser(formData: FormData): Promise<{ data: { data: Customer } }> {
const { data } = await client.post(
customerApi(this.companySlug, '/profile'),
formData,
)
this.userForm = data.data
this.currentUser = data.data
return { data }
},
copyBillingToShipping(): void {
this.userForm.shipping = {
...this.userForm.billing,
type: 'shipping',
}
},
// ---- Auth ----
async login(loginData: CustomerLoginData): Promise<unknown> {
await client.get('/sanctum/csrf-cookie')
const { data } = await client.post(
`/${loginData.company}/customer/login`,
loginData,
)
this.loginData.email = ''
this.loginData.password = ''
return data
},
async forgotPassword(payload: {
email: string
company: string
}): Promise<unknown> {
const { data } = await client.post(
`/api/v1/${payload.company}/customer/auth/password/email`,
payload,
)
return data
},
async resetPassword(
payload: { email: string; password: string; password_confirmation: string; token: string },
company: string,
): Promise<unknown> {
const { data } = await client.post(
`/api/v1/${company}/customer/auth/reset/password`,
payload,
)
return data
},
async logout(): Promise<void> {
await client.post(`/${this.companySlug}/customer/logout`)
},
},
})
export type CustomerPortalStore = ReturnType<typeof useCustomerPortalStore>

View File

@@ -0,0 +1,219 @@
<template>
<BasePage>
<!-- Stats Cards -->
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4 xl:gap-8">
<router-link
:to="{ name: 'customer-portal.invoices' }"
class="p-6 bg-surface border border-line-default rounded-lg hover:shadow-md transition-shadow"
>
<p class="text-sm font-medium text-muted">
{{ $t('dashboard.cards.due_amount') }}
</p>
<div class="mt-2 text-2xl font-semibold text-heading">
<BaseContentPlaceholdersText
v-if="!store.getDashboardDataLoaded"
:lines="1"
class="w-24"
/>
<BaseFormatMoney
v-else
:amount="store.totalDueAmount"
:currency="store.currency"
/>
</div>
</router-link>
<router-link
:to="{ name: 'customer-portal.invoices' }"
class="p-6 bg-surface border border-line-default rounded-lg hover:shadow-md transition-shadow"
>
<p class="text-sm font-medium text-muted">
{{ store.invoiceCount <= 1 ? $t('dashboard.cards.invoices', 1) : $t('dashboard.cards.invoices', 2) }}
</p>
<div class="mt-2 text-2xl font-semibold text-heading">
<BaseContentPlaceholdersText
v-if="!store.getDashboardDataLoaded"
:lines="1"
class="w-16"
/>
<span v-else>{{ store.invoiceCount }}</span>
</div>
</router-link>
<router-link
:to="{ name: 'customer-portal.estimates' }"
class="p-6 bg-surface border border-line-default rounded-lg hover:shadow-md transition-shadow"
>
<p class="text-sm font-medium text-muted">
{{ store.estimateCount <= 1 ? $t('dashboard.cards.estimates', 1) : $t('dashboard.cards.estimates', 2) }}
</p>
<div class="mt-2 text-2xl font-semibold text-heading">
<BaseContentPlaceholdersText
v-if="!store.getDashboardDataLoaded"
:lines="1"
class="w-16"
/>
<span v-else>{{ store.estimateCount }}</span>
</div>
</router-link>
<router-link
:to="{ name: 'customer-portal.payments' }"
class="p-6 bg-surface border border-line-default rounded-lg hover:shadow-md transition-shadow"
>
<p class="text-sm font-medium text-muted">
{{ store.paymentCount <= 1 ? $t('dashboard.cards.payments', 1) : $t('dashboard.cards.payments', 2) }}
</p>
<div class="mt-2 text-2xl font-semibold text-heading">
<BaseContentPlaceholdersText
v-if="!store.getDashboardDataLoaded"
:lines="1"
class="w-16"
/>
<span v-else>{{ store.paymentCount }}</span>
</div>
</router-link>
</div>
<!-- Recent Tables -->
<div class="grid grid-cols-1 gap-6 mt-10 xl:grid-cols-2">
<!-- Recent Invoices -->
<div>
<div class="relative z-10 flex items-center justify-between mb-3">
<h6 class="mb-0 text-xl font-semibold leading-normal">
{{ $t('dashboard.recent_invoices_card.title') }}
</h6>
<BaseButton
size="sm"
variant="primary-outline"
@click="$router.push({ name: 'customer-portal.invoices' })"
>
{{ $t('dashboard.recent_invoices_card.view_all') }}
</BaseButton>
</div>
<BaseTable
:data="store.recentInvoices"
:columns="dueInvoiceColumns"
:loading="!store.getDashboardDataLoaded"
>
<template #cell-invoice_number="{ row }">
<router-link
:to="`/${store.companySlug}/customer/invoices/${row.data.id}/view`"
class="font-medium text-primary-500"
>
{{ row.data.invoice_number }}
</router-link>
</template>
<template #cell-paid_status="{ row }">
<BasePaidStatusBadge :status="row.data.paid_status">
<BaseInvoiceStatusLabel :status="row.data.paid_status" />
</BasePaidStatusBadge>
</template>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.due_amount"
:currency="store.currency"
/>
</template>
</BaseTable>
</div>
<!-- Recent Estimates -->
<div>
<div class="relative z-10 flex items-center justify-between mb-3">
<h6 class="mb-0 text-xl font-semibold leading-normal">
{{ $t('dashboard.recent_estimate_card.title') }}
</h6>
<BaseButton
variant="primary-outline"
size="sm"
@click="$router.push({ name: 'customer-portal.estimates' })"
>
{{ $t('dashboard.recent_estimate_card.view_all') }}
</BaseButton>
</div>
<BaseTable
:data="store.recentEstimates"
:columns="recentEstimateColumns"
:loading="!store.getDashboardDataLoaded"
>
<template #cell-estimate_number="{ row }">
<router-link
:to="`/${store.companySlug}/customer/estimates/${row.data.id}/view`"
class="font-medium text-primary-500"
>
{{ row.data.estimate_number }}
</router-link>
</template>
<template #cell-status="{ row }">
<BaseEstimateStatusBadge :status="row.data.status" class="px-3 py-1">
<BaseEstimateStatusLabel :status="row.data.status" />
</BaseEstimateStatusBadge>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="store.currency"
/>
</template>
</BaseTable>
</div>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCustomerPortalStore } from '../store'
const store = useCustomerPortalStore()
const { t } = useI18n()
onMounted(() => {
store.loadDashboard()
})
interface TableColumn {
key: string
label: string
}
const dueInvoiceColumns = computed<TableColumn[]>(() => [
{
key: 'formattedDueDate',
label: t('dashboard.recent_invoices_card.due_on'),
},
{
key: 'invoice_number',
label: t('invoices.number'),
},
{ key: 'paid_status', label: t('invoices.status') },
{
key: 'due_amount',
label: t('dashboard.recent_invoices_card.amount_due'),
},
])
const recentEstimateColumns = computed<TableColumn[]>(() => [
{
key: 'formattedEstimateDate',
label: t('dashboard.recent_estimate_card.date'),
},
{
key: 'estimate_number',
label: t('estimates.number'),
},
{ key: 'status', label: t('estimates.status') },
{
key: 'total',
label: t('dashboard.recent_estimate_card.amount_due'),
},
])
</script>

View File

@@ -0,0 +1,282 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<div class="mr-3 text-sm">
<BaseButton
v-if="store.selectedViewEstimate?.status === 'DRAFT'"
variant="primary"
@click="acceptEstimate"
>
{{ $t('estimates.accept_estimate') }}
</BaseButton>
</div>
<div class="mr-3 text-sm">
<BaseButton
v-if="store.selectedViewEstimate?.status === 'DRAFT'"
variant="primary-outline"
@click="rejectEstimate"
>
{{ $t('estimates.reject_estimate') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 bg-surface w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-6 border border-line-default border-solid"
>
<BaseInput
v-model="searchData.estimate_number"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearchDebounced"
>
<template #right>
<BaseIcon name="MagnifyingGlassIcon" class="h-5 text-subtle" />
</template>
</BaseInput>
<div class="flex ml-3" role="group">
<BaseDropdown
position="bottom-start"
width-class="w-50"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FunnelIcon" class="h-5" />
</BaseButton>
</template>
<div class="px-4 py-1 pb-2 mb-2 text-sm border-b border-line-default border-solid">
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_estimate_date"
v-model="searchData.orderByField"
:label="$t('reports.estimates.estimate_date')"
size="sm"
name="filter"
value="estimate_date"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_due_date"
v-model="searchData.orderByField"
:label="$t('estimates.due_date')"
value="expiry_date"
size="sm"
name="filter"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_estimate_number"
v-model="searchData.orderByField"
:label="$t('estimates.estimate_number')"
value="estimate_number"
size="sm"
name="filter"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" variant="white" @click="sortData">
<BaseIcon v-if="isAscending" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div class="h-full pb-32 overflow-y-scroll border-l border-line-default border-solid sw-scroll">
<router-link
v-for="(est, index) in store.estimates"
:id="'estimate-' + est.id"
:key="index"
:to="`/${store.companySlug}/customer/estimates/${est.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-hover-strong border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-primary-500 border-solid':
hasActiveUrl(est.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<div class="mb-1 text-md not-italic font-medium leading-5 text-muted capitalize">
{{ est.estimate_number }}
</div>
<BaseEstimateStatusBadge :status="est.status">
<BaseEstimateStatusLabel :status="est.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="est.total"
:currency="est.currency"
/>
<div class="text-sm text-right text-muted non-italic">
{{ est.formatted_estimate_date }}
</div>
</div>
</router-link>
<p
v-if="!store.estimates.length"
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
v-if="shareableLink"
:src="shareableLink"
class="flex-1 border border-gray-400 border-solid rounded-md"
/>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { useCustomerPortalStore } from '../store'
import { EstimateStatus } from '../../../types/domain/estimate'
import type { Estimate } from '../../../types/domain/estimate'
const store = useCustomerPortalStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const estimate = ref<Partial<Estimate>>({})
const searchData = reactive<{
orderBy: string
orderByField: string
estimate_number: string
}>({
orderBy: '',
orderByField: '',
estimate_number: '',
})
const pageTitle = computed<string>(() => {
return store.selectedViewEstimate?.estimate_number ?? ''
})
const isAscending = computed<boolean>(() => {
return searchData.orderBy === 'asc' || !searchData.orderBy
})
const shareableLink = computed<string | false>(() => {
return estimate.value.unique_hash
? `/estimates/pdf/${estimate.value.unique_hash}`
: false
})
watch(() => route.params.id, () => {
loadEstimate()
})
onMounted(() => {
loadEstimates()
loadEstimate()
})
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadEstimates(): Promise<void> {
await store.fetchEstimates({ limit: 'all' })
setTimeout(() => scrollToEstimate(), 500)
}
async function loadEstimate(): Promise<void> {
const id = route.params.id
if (!id) return
const response = await store.fetchViewEstimate(id as string)
if (response.data?.data) {
estimate.value = response.data.data
}
}
function scrollToEstimate(): void {
const el = document.getElementById(`estimate-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch(): Promise<void> {
const params: Record<string, string> = {}
if (searchData.estimate_number) params.estimate_number = searchData.estimate_number
if (searchData.orderBy) params.orderBy = searchData.orderBy
if (searchData.orderByField) params.orderByField = searchData.orderByField
await store.searchEstimates(params)
}
const onSearchDebounced = useDebounceFn(onSearch, 500)
function sortData(): void {
searchData.orderBy = searchData.orderBy === 'asc' ? 'desc' : 'asc'
onSearch()
}
async function acceptEstimate(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_mark_as_accepted', 1))
if (!confirmed) return
await store.updateEstimateStatus(
route.params.id as string,
EstimateStatus.ACCEPTED,
)
router.push({ name: 'customer-portal.estimates' })
}
async function rejectEstimate(): Promise<void> {
const confirmed = window.confirm(t('estimates.confirm_mark_as_rejected', 1))
if (!confirmed) return
await store.updateEstimateStatus(
route.params.id as string,
EstimateStatus.REJECTED,
)
router.push({ name: 'customer-portal.estimates' })
}
</script>

View File

@@ -0,0 +1,263 @@
<template>
<BasePage>
<BasePageHeader :title="$t('estimates.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${store.companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem :title="$t('estimates.estimate', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-if="store.totalEstimates"
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>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$t('estimates.status')" class="px-3">
<BaseSelectInput
v-model="filters.status"
:options="statusOptions"
searchable
:show-labels="false"
:allow-empty="false"
:placeholder="$t('general.select_a_status')"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('estimates.estimate_number')"
color="black-light"
class="px-3 mt-2"
>
<BaseInput v-model="filters.estimate_number">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
<BaseIcon name="HashtagIcon" class="h-5 mr-3 text-body" />
</BaseInput>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')" class="px-3">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('general.to')" class="px-3">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('estimates.no_estimates')"
:description="$t('estimates.list_of_estimates')"
/>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="estimateColumns"
:placeholder-count="store.totalEstimates >= 20 ? 10 : 5"
class="mt-10"
>
<template #cell-estimate_date="{ row }">
{{ row.data.formatted_estimate_date }}
</template>
<template #cell-estimate_number="{ row }">
<router-link
:to="{ path: `estimates/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.estimate_number }}
</router-link>
</template>
<template #cell-status="{ row }">
<BaseEstimateStatusBadge :status="row.data.status" class="px-3 py-1">
<BaseEstimateStatusLabel :status="row.data.status" />
</BaseEstimateStatusBadge>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney :amount="row.data.total" />
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<router-link :to="`estimates/${row.data.id}/view`">
<BaseDropdownItem>
<BaseIcon name="EyeIcon" class="h-5 mr-3 text-body" />
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useCustomerPortalStore } from '../store'
import type { Estimate } from '../../../types/domain/estimate'
const store = useCustomerPortalStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const isFetchingInitialData = ref<boolean>(true)
const showFilters = ref<boolean>(false)
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 {
status: string
from_date: string
to_date: string
estimate_number: string
}
const filters = reactive<EstimateFilters>({
status: '',
from_date: '',
to_date: '',
estimate_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !store.totalEstimates && !isFetchingInitialData.value,
)
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const estimateColumns = computed<TableColumn[]>(() => [
{
key: 'estimate_date',
label: t('estimates.date'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ key: 'estimate_number', label: t('estimates.number', 2) },
{ key: 'status', label: t('estimates.status') },
{ key: 'total', label: t('estimates.total') },
{
key: 'actions',
thClass: 'text-right',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
debouncedWatch(filters, () => refreshTable(), { debounce: 500 })
interface FetchParams {
page: number
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 = {
status: filters.status || undefined,
estimate_number: filters.estimate_number || undefined,
from_date: filters.from_date || undefined,
to_date: filters.to_date || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await store.fetchEstimates(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable(): void {
tableRef.value?.refresh()
}
function clearFilter(): void {
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.estimate_number = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,285 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<BaseButton
:disabled="isSendingEmail"
variant="primary-outline"
class="mr-2"
tag="a"
:href="downloadLink"
download
>
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
{{ $t('invoices.download') }}
</template>
</BaseButton>
<BaseButton
v-if="canPay"
variant="primary"
@click="payInvoice"
>
{{ $t('invoices.pay_invoice') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 bg-surface w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-6 border border-line-default border-solid"
>
<BaseInput
v-model="searchData.invoice_number"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearchDebounced"
>
<template #right>
<BaseIcon name="MagnifyingGlassIcon" class="h-5 text-subtle" />
</template>
</BaseInput>
<div class="flex ml-3" role="group">
<BaseDropdown
position="bottom-start"
width-class="w-50"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FunnelIcon" class="h-5" />
</BaseButton>
</template>
<div class="px-4 py-1 pb-2 mb-2 text-sm border-b border-line-default border-solid">
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_invoice_date"
v-model="searchData.orderByField"
:label="$t('invoices.invoice_date')"
name="filter"
size="sm"
value="invoice_date"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_due_date"
v-model="searchData.orderByField"
:label="$t('invoices.due_date')"
name="filter"
size="sm"
value="due_date"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="pt-3 rounded-md hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_invoice_number"
v-model="searchData.orderByField"
:label="$t('invoices.invoice_number')"
size="sm"
name="filter"
value="invoice_number"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" variant="white" @click="sortData">
<BaseIcon v-if="isAscending" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div class="h-full pb-32 overflow-y-scroll border-l border-line-default border-solid sw-scroll">
<router-link
v-for="(inv, index) in store.invoices"
:id="'invoice-' + inv.id"
:key="index"
:to="`/${store.companySlug}/customer/invoices/${inv.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-hover-strong border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-primary-500 border-solid':
hasActiveUrl(inv.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<div class="mb-1 not-italic font-medium leading-5 text-muted capitalize text-md">
{{ inv.invoice_number }}
</div>
<BaseInvoiceStatusBadge :status="inv.status">
<BaseInvoiceStatusLabel :status="inv.status" />
</BaseInvoiceStatusBadge>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
class="mb-2 text-xl not-italic font-semibold leading-8 text-right text-heading block"
:amount="inv.total"
:currency="inv.currency"
/>
<div class="text-sm text-right text-muted non-italic">
{{ inv.formatted_invoice_date }}
</div>
</div>
</router-link>
<p
v-if="!store.invoices.length"
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
v-if="shareableLink"
:src="shareableLink"
class="flex-1 border border-gray-400 border-solid rounded-md"
/>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebounceFn } from '@vueuse/core'
import { useCustomerPortalStore } from '../store'
import type { Invoice } from '../../../types/domain/invoice'
const store = useCustomerPortalStore()
const route = useRoute()
const router = useRouter()
const invoice = ref<Partial<Invoice>>({})
const isSendingEmail = ref<boolean>(false)
const searchData = reactive<{
orderBy: string
orderByField: string
invoice_number: string
}>({
orderBy: '',
orderByField: '',
invoice_number: '',
})
const pageTitle = computed<string>(() => {
return store.selectedViewInvoice?.invoice_number ?? ''
})
const isAscending = computed<boolean>(() => {
return searchData.orderBy === 'asc' || !searchData.orderBy
})
const shareableLink = computed<string | false>(() => {
return invoice.value.unique_hash
? `/invoices/pdf/${invoice.value.unique_hash}`
: false
})
const downloadLink = computed<string>(() => {
return `/invoices/pdf/${invoice.value.unique_hash ?? ''}`
})
const canPay = computed<boolean>(() => {
return (
store.selectedViewInvoice?.paid_status !== 'PAID' &&
store.enabledModules.includes('Payments')
)
})
watch(() => route.params.id, () => {
loadInvoice()
})
onMounted(() => {
loadInvoices()
loadInvoice()
})
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadInvoices(): Promise<void> {
await store.fetchInvoices({ limit: 'all' })
setTimeout(() => scrollToInvoice(), 500)
}
async function loadInvoice(): Promise<void> {
const id = route.params.id
if (!id) return
const response = await store.fetchViewInvoice(id as string)
if (response.data?.data) {
invoice.value = response.data.data
}
}
function scrollToInvoice(): void {
const el = document.getElementById(`invoice-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch(): Promise<void> {
const params: Record<string, string> = {}
if (searchData.invoice_number) params.invoice_number = searchData.invoice_number
if (searchData.orderBy) params.orderBy = searchData.orderBy
if (searchData.orderByField) params.orderByField = searchData.orderByField
await store.searchInvoices(params)
}
const onSearchDebounced = useDebounceFn(onSearch, 500)
function sortData(): void {
searchData.orderBy = searchData.orderBy === 'asc' ? 'desc' : 'asc'
onSearch()
}
function payInvoice(): void {
if (!store.selectedViewInvoice) return
router.push({
name: 'invoice.portal.payment',
params: {
id: String(store.selectedViewInvoice.id),
company: (store.selectedViewInvoice.company as { slug: string } | undefined)?.slug ?? store.companySlug,
},
})
}
</script>

View File

@@ -0,0 +1,274 @@
<template>
<BasePage>
<BasePageHeader :title="$t('invoices.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${store.companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem :title="$t('invoices.invoice', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="store.totalInvoices"
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>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$t('invoices.status')" class="px-3">
<BaseSelectInput
v-model="filters.status"
:options="statusOptions"
searchable
:allow-empty="false"
:placeholder="$t('general.select_a_status')"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('invoices.invoice_number')"
color="black-light"
class="px-3 mt-2"
>
<BaseInput v-model="filters.invoice_number">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
<BaseIcon name="HashtagIcon" class="h-5 ml-3 text-body" />
</BaseInput>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')" class="px-3">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('general.to')" class="px-3">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('invoices.no_invoices')"
:description="$t('invoices.list_of_invoices')"
/>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="invoiceColumns"
:placeholder-count="store.totalInvoices >= 20 ? 10 : 5"
class="mt-10"
>
<template #cell-invoice_date="{ row }">
{{ row.data.formatted_invoice_date }}
</template>
<template #cell-invoice_number="{ row }">
<router-link
:to="{ path: `invoices/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.invoice_number }}
</router-link>
</template>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer?.currency"
/>
</template>
<template #cell-status="{ row }">
<BaseInvoiceStatusBadge :status="row.data.status" class="px-3 py-1">
<BaseInvoiceStatusLabel :status="row.data.status" />
</BaseInvoiceStatusBadge>
</template>
<template #cell-paid_status="{ row }">
<BaseInvoiceStatusBadge :status="row.data.paid_status" class="px-3 py-1">
<BaseInvoiceStatusLabel :status="row.data.paid_status" />
</BaseInvoiceStatusBadge>
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<router-link :to="`invoices/${row.data.id}/view`">
<BaseDropdownItem>
<BaseIcon name="EyeIcon" class="h-5 mr-3 text-body" />
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useCustomerPortalStore } from '../store'
import type { Invoice } from '../../../types/domain/invoice'
const store = useCustomerPortalStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const isFetchingInitialData = ref<boolean>(true)
const showFilters = ref<boolean>(false)
interface StatusOption {
label: string
value: string
}
const statusOptions = ref<StatusOption[]>([
{ 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' },
])
interface InvoiceFilters {
status: string
from_date: string
to_date: string
invoice_number: string
}
const filters = reactive<InvoiceFilters>({
status: '',
from_date: '',
to_date: '',
invoice_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !store.totalInvoices && !isFetchingInitialData.value,
)
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const invoiceColumns = computed<TableColumn[]>(() => [
{
key: 'invoice_date',
label: t('invoices.date'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ key: 'invoice_number', label: t('invoices.number') },
{ key: 'status', label: t('invoices.status') },
{ key: 'paid_status', label: t('invoices.paid_status') },
{
key: 'due_amount',
label: t('dashboard.recent_invoices_card.amount_due'),
},
{
key: 'actions',
thClass: 'text-right',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
debouncedWatch(filters, () => refreshTable(), { debounce: 500 })
interface FetchParams {
page: number
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 = {
status: filters.status || undefined,
invoice_number: filters.invoice_number || undefined,
from_date: filters.from_date || undefined,
to_date: filters.to_date || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await store.fetchInvoices(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable(): void {
tableRef.value?.refresh()
}
function clearFilter(): void {
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.invoice_number = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,254 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<BaseButton
:disabled="isSendingEmail"
variant="primary-outline"
tag="a"
download
:href="downloadLink"
>
<template #left="slotProps">
<BaseIcon name="DownloadIcon" :class="slotProps.class" />
{{ $t('general.download') }}
</template>
</BaseButton>
</template>
</BasePageHeader>
<!-- Sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 bg-surface w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-6 border border-line-default border-solid"
>
<BaseInput
v-model="searchData.payment_number"
:placeholder="$t('general.search')"
type="text"
variant="gray"
@input="onSearchDebounced"
>
<template #right>
<BaseIcon name="MagnifyingGlassIcon" class="h-5 text-subtle" />
</template>
</BaseInput>
<div class="flex ml-3" role="group">
<BaseDropdown
position="bottom-start"
width-class="w-50"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FunnelIcon" class="h-5" />
</BaseButton>
</template>
<div class="px-4 py-1 pb-2 mb-2 text-sm border-b border-line-default border-solid">
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_invoice_number"
v-model="searchData.orderByField"
:label="$t('invoices.title')"
size="sm"
name="filter"
value="invoice_number"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_payment_date"
v-model="searchData.orderByField"
:label="$t('payments.date')"
size="sm"
name="filter"
value="payment_date"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="rounded-md pt-3 hover:rounded-md">
<BaseInputGroup class="-mt-3 font-normal">
<BaseRadio
id="filter_payment_number"
v-model="searchData.orderByField"
:label="$t('payments.payment_number')"
size="sm"
name="filter"
value="payment_number"
@update:model-value="onSearchDebounced"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" variant="white" @click="sortData">
<BaseIcon v-if="isAscending" name="SortAscendingIcon" class="h-5" />
<BaseIcon v-else name="SortDescendingIcon" class="h-5" />
</BaseButton>
</div>
</div>
<div class="h-full pb-32 overflow-y-scroll border-l border-line-default border-solid sw-scroll">
<router-link
v-for="(pmt, index) in store.payments"
:id="'payment-' + pmt.id"
:key="index"
:to="`/${store.companySlug}/customer/payments/${pmt.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-hover-strong border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-primary-500 border-solid':
hasActiveUrl(pmt.id),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="flex-2">
<div class="mb-1 text-md not-italic font-medium leading-5 text-muted capitalize">
{{ pmt.payment_number }}
</div>
</div>
<div class="flex-1 whitespace-nowrap right">
<BaseFormatMoney
class="mb-2 text-xl not-italic font-semibold leading-8 text-right text-heading block"
:amount="pmt.amount"
:currency="pmt.currency"
/>
<div class="text-sm text-right text-muted non-italic">
{{ pmt.formatted_payment_date }}
</div>
</div>
</router-link>
<p
v-if="!store.payments.length"
class="flex justify-center px-4 mt-5 text-sm text-body"
>
{{ $t('payments.no_matching_payments') }}
</p>
</div>
</div>
<!-- PDF Preview -->
<div class="flex flex-col min-h-0 mt-8 overflow-hidden" style="height: 75vh">
<iframe
v-if="shareableLink"
:src="shareableLink"
class="flex-1 border border-gray-400 border-solid rounded-md"
/>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useDebounceFn } from '@vueuse/core'
import { useCustomerPortalStore } from '../store'
import type { Payment } from '../../../types/domain/payment'
const store = useCustomerPortalStore()
const route = useRoute()
const payment = ref<Partial<Payment>>({})
const isSendingEmail = ref<boolean>(false)
const searchData = reactive<{
orderBy: string
orderByField: string
payment_number: string
}>({
orderBy: '',
orderByField: '',
payment_number: '',
})
const pageTitle = computed<string>(() => {
return store.selectedViewPayment?.payment_number ?? ''
})
const isAscending = computed<boolean>(() => {
return searchData.orderBy === 'asc' || !searchData.orderBy
})
const shareableLink = computed<string | false>(() => {
return payment.value.unique_hash
? `/payments/pdf/${payment.value.unique_hash}`
: false
})
const downloadLink = computed<string>(() => {
return `/payments/pdf/${payment.value.unique_hash ?? ''}`
})
watch(() => route.params.id, () => {
loadPayment()
})
onMounted(() => {
loadPayments()
loadPayment()
})
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadPayments(): Promise<void> {
await store.fetchPayments({ limit: 'all' })
setTimeout(() => scrollToPayment(), 500)
}
async function loadPayment(): Promise<void> {
const id = route.params.id
if (!id) return
const response = await store.fetchViewPayment(id as string)
if (response.data?.data) {
payment.value = response.data.data
}
}
function scrollToPayment(): void {
const el = document.getElementById(`payment-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch(): Promise<void> {
const params: Record<string, string> = {}
if (searchData.payment_number) params.payment_number = searchData.payment_number
if (searchData.orderBy) params.orderBy = searchData.orderBy
if (searchData.orderByField) params.orderByField = searchData.orderByField
await store.searchPayments(params)
}
const onSearchDebounced = useDebounceFn(onSearch, 500)
function sortData(): void {
searchData.orderBy = searchData.orderBy === 'asc' ? 'desc' : 'asc'
onSearch()
}
</script>

View File

@@ -0,0 +1,237 @@
<template>
<BasePage>
<BasePageHeader :title="$t('payments.title')">
<template #breadcrumbs>
<BaseBreadcrumb>
<BaseBreadcrumbItem
:title="$t('general.home')"
:to="`/${store.companySlug}/customer/dashboard`"
/>
<BaseBreadcrumbItem :title="$t('payments.payment', 2)" to="#" active />
</BaseBreadcrumb>
</template>
<template #actions>
<BaseButton
v-show="store.totalPayments"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
:class="slotProps.class"
name="FunnelIcon"
/>
<BaseIcon v-else :class="slotProps.class" name="XMarkIcon" />
</template>
</BaseButton>
</template>
</BasePageHeader>
<BaseFilterWrapper v-show="showFilters" @clear="clearFilter">
<BaseInputGroup :label="$t('payments.payment_number')" class="px-3">
<BaseInput
v-model="filters.payment_number"
:placeholder="$t('payments.payment_number')"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('payments.payment_mode')" class="px-3">
<BaseMultiselect
v-model="filters.payment_mode"
value-prop="id"
track-by="name"
:filter-results="false"
label="name"
resolve-on-load
:delay="100"
searchable
:options="searchPaymentModes"
:placeholder="$t('payments.payment_mode')"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-if="showEmptyScreen"
:title="$t('payments.no_payments')"
:description="$t('payments.list_of_payments')"
/>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="paymentColumns"
:placeholder-count="store.totalPayments >= 20 ? 10 : 5"
class="mt-10"
>
<template #cell-payment_date="{ row }">
{{ row.data.formatted_payment_date }}
</template>
<template #cell-payment_number="{ row }">
<router-link
:to="{ path: `payments/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.payment_number }}
</router-link>
</template>
<template #cell-payment_mode="{ row }">
<span>
{{ row.data.payment_method?.name ?? $t('payments.not_selected') }}
</span>
</template>
<template #cell-invoice_number="{ row }">
<span>
{{ row.data.invoice?.invoice_number ?? $t('payments.no_invoice') }}
</span>
</template>
<template #cell-amount="{ row }">
<BaseFormatMoney
:amount="row.data.amount"
:currency="store.currency"
/>
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="w-5 text-muted" />
</template>
<router-link :to="`payments/${row.data.id}/view`">
<BaseDropdownItem>
<BaseIcon name="EyeIcon" class="h-5 mr-3 text-body" />
{{ $t('general.view') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useCustomerPortalStore } from '../store'
import type { Payment, PaymentMethod } from '../../../types/domain/payment'
const store = useCustomerPortalStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const isFetchingInitialData = ref<boolean>(true)
const showFilters = ref<boolean>(false)
interface PaymentFilters {
payment_mode: string | number
payment_number: string
}
const filters = reactive<PaymentFilters>({
payment_mode: '',
payment_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !store.totalPayments && !isFetchingInitialData.value,
)
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const paymentColumns = computed<TableColumn[]>(() => [
{
key: 'payment_date',
label: t('payments.date'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{ key: 'payment_number', label: t('payments.payment_number') },
{ key: 'payment_mode', label: t('payments.payment_mode') },
{ key: 'invoice_number', label: t('invoices.invoice_number') },
{ key: 'amount', label: t('payments.amount') },
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
debouncedWatch(filters, () => refreshTable(), { debounce: 500 })
async function searchPaymentModes(search: string): Promise<PaymentMethod[]> {
return store.fetchPaymentModes(search)
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: Payment[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
payment_method_id: filters.payment_mode || undefined,
payment_number: filters.payment_number || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
const response = await store.fetchPayments(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function refreshTable(): void {
tableRef.value?.refresh()
}
function clearFilter(): void {
filters.payment_mode = ''
filters.payment_number = ''
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,218 @@
<template>
<form class="relative h-full mt-4" @submit.prevent="updateCustomerData">
<BaseCard>
<div>
<h6 class="font-bold text-left">
{{ $t('settings.account_settings.account_settings') }}
</h6>
<p
class="mt-2 text-sm leading-snug text-left text-muted"
style="max-width: 680px"
>
{{ $t('settings.account_settings.section_description') }}
</p>
</div>
<div class="grid gap-6 sm:grid-col-1 md:grid-cols-2 mt-6">
<BaseInputGroup
:label="$t('settings.account_settings.profile_picture')"
>
<BaseFileUploader
v-model="imgFiles"
:avatar="true"
accept="image/*"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
<span />
<BaseInputGroup
:label="$t('settings.account_settings.name')"
:error="v$.name.$error ? String(v$.name.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.account_settings.email')"
:error="v$.email.$error ? String(v$.email.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model="formData.email"
:invalid="v$.email.$error"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="v$.password.$error ? String(v$.password.$errors[0]?.$message) : undefined"
:label="$t('settings.account_settings.password')"
>
<BaseInput
v-model="formData.password"
:type="isShowPassword ? 'text' : '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
:label="$t('settings.account_settings.confirm_password')"
:error="v$.confirm_password.$error ? String(v$.confirm_password.$errors[0]?.$message) : undefined"
>
<BaseInput
v-model="formData.confirm_password"
:type="isShowConfirmPassword ? 'text' : '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>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-6">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
helpers,
sameAs,
email,
required,
minLength,
} from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useCustomerPortalStore } from '../store'
interface AvatarFile {
image: string
}
const store = useCustomerPortalStore()
const { t, tm } = useI18n()
const imgFiles = ref<AvatarFile[]>([])
const isSaving = ref<boolean>(false)
const avatarFileBlob = ref<File | null>(null)
const isShowPassword = ref<boolean>(false)
const isShowConfirmPassword = ref<boolean>(false)
const isCustomerAvatarRemoved = ref<boolean>(false)
const formData = reactive<{
name: string
email: string
password: string
confirm_password: string
}>({
name: store.userForm.name,
email: store.userForm.email,
password: '',
confirm_password: '',
})
if (store.userForm.avatar) {
imgFiles.value.push({ image: store.userForm.avatar })
}
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3),
),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8),
),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(formData.password),
),
},
}))
const v$ = useVuelidate(rules, formData)
function onFileInputChange(_fileName: string, file: File): void {
avatarFileBlob.value = file
}
function onFileInputRemove(): void {
avatarFileBlob.value = null
isCustomerAvatarRemoved.value = true
}
async function updateCustomerData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
const data = new FormData()
data.append('name', formData.name)
data.append('email', formData.email)
if (formData.password) {
data.append('password', formData.password)
}
if (avatarFileBlob.value) {
data.append('customer_avatar', avatarFileBlob.value)
}
data.append('is_customer_avatar_removed', String(isCustomerAvatarRemoved.value))
try {
const res = await store.updateCurrentUser(data)
if (res.data.data) {
formData.password = ''
formData.confirm_password = ''
avatarFileBlob.value = null
isCustomerAvatarRemoved.value = false
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,11 @@
export { installationRoutes } from './routes'
// Views
export { default as RequirementsView } from './views/RequirementsView.vue'
export { default as PermissionsView } from './views/PermissionsView.vue'
export { default as DatabaseView } from './views/DatabaseView.vue'
export { default as DomainView } from './views/DomainView.vue'
export { default as MailView } from './views/MailView.vue'
export { default as AccountView } from './views/AccountView.vue'
export { default as CompanyView } from './views/CompanyView.vue'
export { default as PreferencesView } from './views/PreferencesView.vue'

View File

@@ -0,0 +1,93 @@
import type { RouteRecordRaw } from 'vue-router'
/**
* The installation wizard is a multi-step flow rendered inside a single
* parent view. Individual step views are not routed independently -- they
* are controlled by the parent Installation component via dynamic
* components. This route simply mounts the wizard entry point.
*
* The individual step views are:
* 1. RequirementsView
* 2. PermissionsView
* 3. DatabaseView
* 4. DomainView
* 5. MailView
* 6. AccountView
* 7. CompanyView
* 8. PreferencesView
*/
export const installationRoutes: RouteRecordRaw[] = [
{
path: '/installation',
name: 'installation',
component: () => import('./views/RequirementsView.vue'),
meta: {
title: 'wizard.req.system_req',
isInstallation: true,
},
},
{
path: '/installation/permissions',
name: 'installation.permissions',
component: () => import('./views/PermissionsView.vue'),
meta: {
title: 'wizard.permissions.permissions',
isInstallation: true,
},
},
{
path: '/installation/database',
name: 'installation.database',
component: () => import('./views/DatabaseView.vue'),
meta: {
title: 'wizard.database.database',
isInstallation: true,
},
},
{
path: '/installation/domain',
name: 'installation.domain',
component: () => import('./views/DomainView.vue'),
meta: {
title: 'wizard.verify_domain.title',
isInstallation: true,
},
},
{
path: '/installation/mail',
name: 'installation.mail',
component: () => import('./views/MailView.vue'),
meta: {
title: 'wizard.mail.mail_config',
isInstallation: true,
},
},
{
path: '/installation/account',
name: 'installation.account',
component: () => import('./views/AccountView.vue'),
meta: {
title: 'wizard.account_info',
isInstallation: true,
},
},
{
path: '/installation/company',
name: 'installation.company',
component: () => import('./views/CompanyView.vue'),
meta: {
title: 'wizard.company_info',
isInstallation: true,
},
},
{
path: '/installation/preferences',
name: 'installation.preferences',
component: () => import('./views/PreferencesView.vue'),
meta: {
title: 'wizard.preferences',
isInstallation: true,
},
},
]

View File

@@ -0,0 +1,209 @@
<template>
<BaseWizardStep
:title="$t('wizard.account_info')"
:description="$t('wizard.account_info_desc')"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('settings.account_settings.profile_picture')">
<BaseFileUploader
:avatar="true"
:preview-image="avatarUrl"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.name')"
:error="v$.name.$error ? String(v$.name.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.name"
:invalid="v$.name.$error"
type="text"
name="name"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.email')"
:error="v$.email.$error ? String(v$.email.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.email"
:invalid="v$.email.$error"
type="text"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.password')"
:error="v$.password.$error ? String(v$.password.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.password"
:invalid="v$.password.$error"
:type="isShowPassword ? 'text' : 'password'"
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>
<BaseInputGroup
:label="$t('wizard.confirm_password')"
:error="v$.confirm_password.$error ? String(v$.confirm_password.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="userForm.confirm_password"
:invalid="v$.confirm_password.$error"
:type="isShowConfirmPassword ? 'text' : 'password'"
name="confirm_password"
@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>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
helpers,
required,
requiredIf,
sameAs,
minLength,
email,
} from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { client } from '../../../api/client'
interface UserForm {
name: string
email: string
password: string
confirm_password: string
}
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isShowPassword = ref<boolean>(false)
const isShowConfirmPassword = ref<boolean>(false)
const avatarUrl = ref<string>('')
const avatarFileBlob = ref<File | null>(null)
const userForm = reactive<UserForm>({
name: '',
email: '',
password: '',
confirm_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: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8),
),
},
confirm_password: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(() => !!userForm.password),
),
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(computed(() => userForm.password)),
),
},
}))
const v$ = useVuelidate(rules, userForm)
function onFileInputChange(_fileName: string, file: File): void {
avatarFileBlob.value = file
}
function onFileInputRemove(): void {
avatarFileBlob.value = null
}
async function next(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
const { data: res } = await client.put('/api/v1/me', userForm)
if (res.data) {
if (avatarFileBlob.value) {
const avatarData = new FormData()
avatarData.append('admin_avatar', avatarFileBlob.value)
await client.post('/api/v1/me/upload-avatar', avatarData)
}
const company = res.data.companies?.[0]
if (company) {
localStorage.setItem('selectedCompany', String(company.id))
}
emit('next', 6)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,254 @@
<template>
<BaseWizardStep
:title="$t('wizard.company_info')"
:description="$t('wizard.company_info_desc')"
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('settings.company_info.company_logo')">
<BaseFileUploader
base64
:preview-image="previewLogo"
@change="onFileInputChange"
@remove="onFileInputRemove"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.company_name')"
:error="v$.name.$error ? String(v$.name.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model.trim="companyForm.name"
:invalid="v$.name.$error"
type="text"
name="name"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.country')"
:error="v$.country_id.$error ? String(v$.country_id.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="companyForm.address.country_id"
label="name"
:invalid="v$.country_id.$error"
:options="countries"
value-prop="id"
:can-deselect="false"
:can-clear="false"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.select_country')"
searchable
track-by="name"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.state')">
<BaseInput v-model="companyForm.address.state" name="state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.city')">
<BaseInput v-model="companyForm.address.city" name="city" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<div>
<BaseInputGroup
:label="$t('wizard.address')"
:error="v$.address_street_1.$error ? String(v$.address_street_1.$errors[0]?.$message) : undefined"
>
<BaseTextarea
v-model.trim="companyForm.address.address_street_1"
:invalid="v$.address_street_1.$error"
:placeholder="$t('general.street_1')"
name="billing_street1"
rows="2"
@input="v$.address_street_1.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup class="mt-1 lg:mt-2 md:mt-2">
<BaseTextarea
v-model="companyForm.address.address_street_2"
:placeholder="$t('general.street_2')"
name="billing_street2"
rows="2"
/>
</BaseInputGroup>
</div>
<div>
<BaseInputGroup :label="$t('wizard.zip_code')">
<BaseInput v-model.trim="companyForm.address.zip" type="text" name="zip" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.phone')" class="mt-4">
<BaseInput v-model.trim="companyForm.address.phone" type="text" name="phone" />
</BaseInputGroup>
</div>
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model.trim="companyForm.tax_id" type="text" name="tax_id" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model.trim="companyForm.vat_id" type="text" name="vat_id" />
</BaseInputGroup>
</div>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, maxLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
import type { Country } from '../../../types/domain/customer'
interface CompanyAddress {
address_street_1: string
address_street_2: string
website: string
country_id: number | null
state: string
city: string
phone: string
zip: string
}
interface CompanyFormData {
name: string | null
tax_id: string | null
vat_id: string | null
address: CompanyAddress
}
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isFetchingInitialData = ref<boolean>(false)
const isSaving = ref<boolean>(false)
const previewLogo = ref<string | null>(null)
const logoFileBlob = ref<string | null>(null)
const logoFileName = ref<string | null>(null)
const countries = ref<Country[]>([])
const companyForm = reactive<CompanyFormData>({
name: null,
tax_id: null,
vat_id: null,
address: {
address_street_1: '',
address_street_2: '',
website: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
country_id: {
required: helpers.withMessage(t('validation.required'), required),
},
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255),
),
},
}))
const validationState = computed(() => ({
name: companyForm.name,
country_id: companyForm.address.country_id,
address_street_1: companyForm.address.address_street_1,
}))
const v$ = useVuelidate(rules, validationState)
onMounted(async () => {
isFetchingInitialData.value = true
try {
const { data } = await client.get(API.COUNTRIES)
countries.value = data.data ?? data
// Default to US
const us = countries.value.find((c) => c.code === 'US')
if (us) companyForm.address.country_id = us.id
} finally {
isFetchingInitialData.value = false
}
})
function onFileInputChange(
_fileName: string,
file: string,
_fileCount: number,
fileList: { name: string },
): void {
logoFileName.value = fileList.name
logoFileBlob.value = file
}
function onFileInputRemove(): void {
logoFileBlob.value = null
}
async function next(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await client.put(API.COMPANY, companyForm)
if (logoFileBlob.value) {
const logoData = new FormData()
logoData.append(
'company_logo',
JSON.stringify({
name: logoFileName.value,
data: logoFileBlob.value,
}),
)
await client.post(API.COMPANY_UPLOAD_LOGO, logoData)
}
emit('next', 7)
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<BaseWizardStep
:title="$t('wizard.database.database')"
:description="$t('wizard.database.desc')"
step-container="w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded md:w-full"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.database.connection')"
required
>
<BaseMultiselect
v-model="databaseData.database_connection"
:options="databaseDrivers"
label="label"
value-prop="value"
:can-deselect="false"
:can-clear="false"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
</div>
<!-- MySQL / PostgreSQL fields -->
<template v-if="databaseData.database_connection !== 'sqlite'">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.database.hostname')" required>
<BaseInput v-model="databaseData.database_hostname" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.database.port')" required>
<BaseInput v-model="databaseData.database_port" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.database.db_name')" required>
<BaseInput v-model="databaseData.database_name" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.database.username')" required>
<BaseInput v-model="databaseData.database_username" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup :label="$t('wizard.database.password')">
<BaseInput v-model="databaseData.database_password" type="password" />
</BaseInputGroup>
</div>
</template>
<!-- SQLite fields -->
<template v-else>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup :label="$t('wizard.database.db_name')">
<BaseInput v-model="databaseData.database_name" type="text" disabled />
</BaseInputGroup>
</div>
</template>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.continue') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { client } from '../../../api/client'
interface DatabaseConfig {
database_connection: string
database_hostname: string
database_port: string
database_name: string | null
database_username: string | null
database_password: string | null
database_overwrite: boolean
app_url: string
app_locale: string | null
}
interface Emits {
(e: 'next', step: number): void
}
interface DatabaseDriverOption {
label: string
value: string
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const databaseDrivers = ref<DatabaseDriverOption[]>([
{ label: 'MySQL', value: 'mysql' },
{ label: 'PostgreSQL', value: 'pgsql' },
{ label: 'SQLite', value: 'sqlite' },
])
const databaseData = reactive<DatabaseConfig>({
database_connection: 'mysql',
database_hostname: '127.0.0.1',
database_port: '3306',
database_name: null,
database_username: null,
database_password: null,
database_overwrite: false,
app_url: window.location.origin,
app_locale: null,
})
onMounted(() => {
getDatabaseConfig()
})
async function getDatabaseConfig(connection?: string): Promise<void> {
const params: Record<string, string> = {}
if (connection) params.connection = connection
const { data } = await client.get('/api/v1/installation/database/config', { params })
if (data.success) {
databaseData.database_connection = data.config.database_connection
}
if (data.config.database_connection === 'sqlite') {
databaseData.database_name = data.config.database_name
} else {
databaseData.database_name = null
if (data.config.database_host) {
databaseData.database_hostname = data.config.database_host
}
if (data.config.database_port) {
databaseData.database_port = data.config.database_port
}
}
}
function onChangeDriver(connection: string): void {
getDatabaseConfig(connection)
}
async function next(): Promise<void> {
isSaving.value = true
try {
const { data: res } = await client.post(
'/api/v1/installation/database/config',
databaseData,
)
if (res.success) {
await client.post('/api/v1/installation/finish')
emit('next', 3)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template>
<BaseWizardStep
:title="$t('wizard.verify_domain.title')"
:description="$t('wizard.verify_domain.desc')"
>
<div class="w-full">
<BaseInputGroup
:label="$t('wizard.verify_domain.app_domain')"
:error="v$.app_domain.$error ? String(v$.app_domain.$errors[0]?.$message) : undefined"
required
>
<BaseInput
v-model="formData.app_domain"
:invalid="v$.app_domain.$error"
type="text"
@input="v$.app_domain.$touch()"
/>
</BaseInputGroup>
</div>
<p class="mt-4 mb-0 text-sm text-body">
{{ $t('wizard.verify_domain.notes.notes') }}
</p>
<ul class="w-full text-body list-disc list-inside">
<li class="text-sm leading-8">
{{ $t('wizard.verify_domain.notes.not_contain') }}
<b class="inline-block px-1 bg-surface-tertiary rounded-xs">https://</b>
{{ $t('wizard.verify_domain.notes.or') }}
<b class="inline-block px-1 bg-surface-tertiary rounded-xs">http</b>
{{ $t('wizard.verify_domain.notes.in_front') }}
</li>
<li class="text-sm leading-8">
{{ $t('wizard.verify_domain.notes.if_you') }}
<b class="inline-block px-1 bg-surface-tertiary">localhost:8080</b>
</li>
</ul>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
class="mt-8"
@click="verifyDomain"
>
{{ $t('wizard.verify_domain.verify_now') }}
</BaseButton>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { client } from '../../../api/client'
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const formData = reactive<{ app_domain: string }>({
app_domain: window.location.origin.replace(/(^\w+:|^)\/\//, ''),
})
function isUrl(value: string): boolean {
if (!value) return false
// Simple domain validation -- no protocol prefix
return !value.startsWith('http://') && !value.startsWith('https://')
}
const rules = computed(() => ({
app_domain: {
required: helpers.withMessage(t('validation.required'), required),
isUrl: helpers.withMessage(t('validation.invalid_domain_url'), isUrl),
},
}))
const v$ = useVuelidate(rules, formData)
async function verifyDomain(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
isSaving.value = true
try {
await client.put('/api/v1/installation/set-domain', formData)
await client.get('/sanctum/csrf-cookie')
await client.post('/api/v1/installation/login')
const { data } = await client.get('/api/v1/auth/check')
if (data) {
emit('next', 4)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,168 @@
<template>
<BaseWizardStep
:title="$t('wizard.mail.mail_config')"
:description="$t('wizard.mail.mail_config_desc')"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.driver')" required>
<BaseMultiselect
v-model="mailDriver"
:options="mailDriverOptions"
label="label"
value-prop="value"
:can-deselect="false"
:can-clear="false"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
</div>
<!-- SMTP Fields -->
<template v-if="mailDriver === 'smtp'">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.host')" required>
<BaseInput v-model="mailConfig.mail_host" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.mail.port')" required>
<BaseInput v-model="mailConfig.mail_port" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.username')">
<BaseInput v-model="mailConfig.mail_username" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.mail.password')">
<BaseInput v-model="mailConfig.mail_password" type="password" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup :label="$t('wizard.mail.encryption')">
<BaseMultiselect
v-model="mailConfig.mail_encryption"
:options="encryptionOptions"
:can-deselect="true"
:placeholder="$t('wizard.mail.none')"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('wizard.mail.from_mail')">
<BaseInput v-model="mailConfig.from_mail" type="text" />
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup :label="$t('wizard.mail.from_name')">
<BaseInput v-model="mailConfig.from_name" type="text" />
</BaseInputGroup>
</div>
</template>
<!-- Basic driver info -->
<template v-if="mailDriver === 'sendmail' || mailDriver === 'mail'">
<p class="text-sm text-muted mb-6">
{{ $t('wizard.mail.basic_mail_desc') }}
</p>
</template>
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { client } from '../../../api/client'
interface MailConfig {
mail_driver: string
mail_host: string
mail_port: string
mail_username: string
mail_password: string
mail_encryption: string
from_mail: string
from_name: string
[key: string]: string
}
interface DriverOption {
label: string
value: string
}
interface Emits {
(e: 'next', step: number): void
}
const emit = defineEmits<Emits>()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const mailDriver = ref<string>('smtp')
const mailDriverOptions = ref<DriverOption[]>([
{ label: 'SMTP', value: 'smtp' },
{ label: 'Mailgun', value: 'mailgun' },
{ label: 'SES', value: 'ses' },
{ label: 'Sendmail', value: 'sendmail' },
{ label: 'Mail', value: 'mail' },
])
const encryptionOptions = ref<string[]>(['tls', 'ssl'])
const mailConfig = reactive<MailConfig>({
mail_driver: 'smtp',
mail_host: '',
mail_port: '587',
mail_username: '',
mail_password: '',
mail_encryption: 'tls',
from_mail: '',
from_name: '',
})
onMounted(async () => {
await loadData()
})
async function loadData(): Promise<void> {
isFetchingInitialData.value = true
try {
const { data: configData } = await client.get('/api/v1/mail/config')
if (configData) {
Object.assign(mailConfig, configData)
mailDriver.value = configData.mail_driver ?? 'smtp'
}
} finally {
isFetchingInitialData.value = false
}
}
function onChangeDriver(value: string): void {
mailDriver.value = value
mailConfig.mail_driver = value
}
async function next(): Promise<void> {
isSaving.value = true
try {
mailConfig.mail_driver = mailDriver.value
const { data } = await client.post('/api/v1/mail/config', mailConfig)
if (data.success) {
emit('next', 5)
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,99 @@
<template>
<BaseWizardStep
:title="$t('wizard.permissions.permissions')"
:description="$t('wizard.permissions.permission_desc')"
>
<!-- Placeholders -->
<BaseContentPlaceholders v-if="isFetchingInitialData">
<div
v-for="n in 3"
:key="n"
class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4 border border-line-default"
>
<BaseContentPlaceholdersText :lines="1" class="col-span-4 p-3" />
</div>
<BaseContentPlaceholdersBox
:rounded="true"
class="mt-10"
style="width: 96px; height: 42px"
/>
</BaseContentPlaceholders>
<div v-else class="relative">
<div
v-for="(permission, index) in permissions"
:key="index"
class="border border-line-default"
>
<div class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4">
<div class="col-span-2 p-3">{{ permission.folder }}</div>
<div class="p-3 text-right">
<span
v-if="permission.isSet"
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-green-500"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-red-500"
/>
<span>{{ permission.permission }}</span>
</div>
</div>
</div>
<BaseButton
v-show="!isFetchingInitialData"
class="mt-10"
:loading="isSaving"
:disabled="isSaving"
@click="next"
>
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.continue') }}
</BaseButton>
</div>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { client } from '../../../api/client'
interface Permission {
folder: string
permission: string
isSet: boolean
}
interface Emits {
(e: 'next'): void
}
const emit = defineEmits<Emits>()
const isFetchingInitialData = ref<boolean>(false)
const isSaving = ref<boolean>(false)
const permissions = ref<Permission[]>([])
onMounted(() => {
getPermissions()
})
async function getPermissions(): Promise<void> {
isFetchingInitialData.value = true
try {
const { data } = await client.get('/api/v1/installation/permissions')
permissions.value = data.permissions?.permissions ?? []
} finally {
isFetchingInitialData.value = false
}
}
function next(): void {
isSaving.value = true
emit('next')
isSaving.value = false
}
</script>

View File

@@ -0,0 +1,270 @@
<template>
<BaseWizardStep
:title="$t('wizard.preferences')"
:description="$t('wizard.preferences_desc')"
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
>
<form @submit.prevent="next">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.currency')"
:error="v$.currency.$error ? String(v$.currency.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.currency"
:content-loading="isFetchingInitialData"
:options="currencies"
label="name"
value-prop="id"
:searchable="true"
track-by="name"
:placeholder="$t('settings.currencies.select_currency')"
:invalid="v$.currency.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.preferences.default_language')"
:error="v$.language.$error ? String(v$.language.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.language"
:content-loading="isFetchingInitialData"
:options="languages"
label="name"
value-prop="code"
:placeholder="$t('settings.preferences.select_language')"
class="w-full"
track-by="name"
:searchable="true"
:invalid="v$.language.$error"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
<BaseInputGroup
:label="$t('wizard.date_format')"
:error="v$.carbon_date_format.$error ? String(v$.carbon_date_format.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.carbon_date_format"
:content-loading="isFetchingInitialData"
:options="dateFormats"
label="display_date"
value-prop="carbon_format_value"
:placeholder="$t('settings.preferences.select_date_format')"
track-by="display_date"
searchable
:invalid="v$.carbon_date_format.$error"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('wizard.time_zone')"
:error="v$.time_zone.$error ? String(v$.time_zone.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.time_zone"
:content-loading="isFetchingInitialData"
:options="timeZones"
label="key"
value-prop="value"
:placeholder="$t('settings.preferences.select_time_zone')"
track-by="key"
:searchable="true"
:invalid="v$.time_zone.$error"
/>
</BaseInputGroup>
</div>
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
<BaseInputGroup
:label="$t('wizard.fiscal_year')"
:error="v$.fiscal_year.$error ? String(v$.fiscal_year.$errors[0]?.$message) : undefined"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currentPreferences.fiscal_year"
:content-loading="isFetchingInitialData"
:options="fiscalYearsList"
label="key"
value-prop="value"
:placeholder="$t('settings.preferences.select_financial_year')"
:invalid="v$.fiscal_year.$error"
track-by="key"
:searchable="true"
class="w-full"
/>
</BaseInputGroup>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isFetchingInitialData"
class="mt-4"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ $t('wizard.save_cont') }}
</BaseButton>
</form>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { client } from '../../../api/client'
import { API } from '../../../api/endpoints'
interface PreferencesData {
currency: number
language: string
carbon_date_format: string
time_zone: string
fiscal_year: string
}
interface KeyValueOption {
key: string
value: string
}
interface DateFormatOption {
display_date: string
carbon_format_value: string
}
interface CurrencyOption {
id: number
name: string
}
interface LanguageOption {
code: string
name: string
}
interface Emits {
(e: 'next', step: string): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
const router = useRouter()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const currencies = ref<CurrencyOption[]>([])
const languages = ref<LanguageOption[]>([])
const dateFormats = ref<DateFormatOption[]>([])
const timeZones = ref<KeyValueOption[]>([])
const fiscalYears = ref<KeyValueOption[]>([])
const currentPreferences = reactive<PreferencesData>({
currency: 3,
language: 'en',
carbon_date_format: 'd M Y',
time_zone: 'UTC',
fiscal_year: '1-12',
})
const fiscalYearsList = computed<KeyValueOption[]>(() => {
return fiscalYears.value.map((item) => ({
...item,
key: t(item.key),
}))
})
const rules = computed(() => ({
currency: {
required: helpers.withMessage(t('validation.required'), required),
},
language: {
required: helpers.withMessage(t('validation.required'), required),
},
carbon_date_format: {
required: helpers.withMessage(t('validation.required'), required),
},
time_zone: {
required: helpers.withMessage(t('validation.required'), required),
},
fiscal_year: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, currentPreferences)
onMounted(async () => {
isFetchingInitialData.value = true
try {
const [currRes, dateRes, tzRes, fyRes, langRes] = await Promise.all([
client.get(API.CURRENCIES),
client.get(API.DATE_FORMATS),
client.get(API.TIMEZONES),
client.get(`${API.CONFIG}?key=fiscal_years`),
client.get(`${API.CONFIG}?key=languages`),
])
currencies.value = currRes.data.data ?? currRes.data
dateFormats.value = dateRes.data.data ?? dateRes.data
timeZones.value = tzRes.data.data ?? tzRes.data
fiscalYears.value = fyRes.data.data ?? fyRes.data ?? []
languages.value = langRes.data.data ?? langRes.data ?? []
} finally {
isFetchingInitialData.value = false
}
})
async function next(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
const confirmed = window.confirm(t('wizard.currency_set_alert'))
if (!confirmed) return
isSaving.value = true
try {
const settingsPayload = {
settings: { ...currentPreferences },
}
const { data: res } = await client.post(API.COMPANY_SETTINGS, settingsPayload)
if (res) {
const userSettings = {
settings: { language: currentPreferences.language },
}
await client.put(API.ME_SETTINGS, userSettings)
if (res.token) {
localStorage.setItem('auth.token', res.token)
}
emit('next', 'COMPLETED')
router.push('/admin/dashboard')
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<BaseWizardStep
:title="$t('wizard.req.system_req')"
:description="$t('wizard.req.system_req_desc')"
>
<div class="w-full">
<div class="mb-6">
<div
v-if="phpSupportInfo"
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
>
<div class="col-span-2 text-sm">
{{ $t('wizard.req.php_req_version', { version: phpSupportInfo.minimum }) }}
</div>
<div class="text-right">
{{ phpSupportInfo.current }}
<span
v-if="phpSupportInfo.supported"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</div>
</div>
<div v-if="requirements">
<div
v-for="(fulfilled, name) in requirements"
:key="name"
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
>
<div class="col-span-2 text-sm">{{ name }}</div>
<div class="text-right">
<span
v-if="fulfilled"
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
/>
<span
v-else
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
/>
</div>
</div>
</div>
</div>
<BaseButton v-if="hasNext" @click="next">
{{ $t('wizard.continue') }}
<template #left="slotProps">
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="!requirements"
:loading="isSaving"
:disabled="isSaving"
@click="getRequirements"
>
{{ $t('wizard.req.check_req') }}
</BaseButton>
</div>
</BaseWizardStep>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { client } from '../../../api/client'
interface PhpSupportInfo {
minimum: string
current: string
supported: boolean
}
interface Emits {
(e: 'next'): void
}
const emit = defineEmits<Emits>()
const requirements = ref<Record<string, boolean> | null>(null)
const phpSupportInfo = ref<PhpSupportInfo | null>(null)
const isSaving = ref<boolean>(false)
const hasNext = computed<boolean>(() => {
if (!requirements.value || !phpSupportInfo.value) return false
const allMet = Object.values(requirements.value).every((v) => v)
return allMet && phpSupportInfo.value.supported
})
onMounted(() => {
getRequirements()
})
async function getRequirements(): Promise<void> {
isSaving.value = true
try {
const { data } = await client.get('/api/v1/installation/requirements')
requirements.value = data?.requirements?.requirements?.php ?? null
phpSupportInfo.value = data?.phpSupportInfo ?? null
} finally {
isSaving.value = false
}
}
function next(): void {
isSaving.value = true
emit('next')
isSaving.value = false
}
</script>