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