Phase 1: TypeScript foundation in scripts-v2/

Create the complete TypeScript foundation for the Vue 3 migration
in a parallel scripts-v2/ directory. 72 files, 5430 lines, zero
any types, strict mode.

- types/ (21 files): Domain interfaces for all 17 entities derived
  from actual Laravel models and API resources. Enums for all
  statuses. Generic API response wrappers.
- api/ (29 files): Typed axios client with interceptors, endpoint
  constants from routes/api.php, 25 typed service classes covering
  every API endpoint.
- composables/ (14 files): Vue 3 composition functions for auth,
  notifications, dialogs, modals, pagination, filters, currency,
  dates, theme, sidebar, company context, and permissions.
- utils/ (5 files): Pure typed utilities for money formatting,
  date formatting (date-fns), localStorage, and error handling.
- config/ (3 files): Typed ability constants, app constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 05:00:00 +02:00
parent 53a0e1491d
commit 991b716b33
74 changed files with 5510 additions and 4 deletions

View File

@@ -0,0 +1,28 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
const client: AxiosInstance = axios.create({
withCredentials: true,
headers: {
common: {
'X-Requested-With': 'XMLHttpRequest',
},
},
})
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const companyId = localStorage.getItem('selectedCompany')
const authToken = localStorage.getItem('auth.token')
const isAdminMode = localStorage.getItem('isAdminMode') === 'true'
if (authToken) {
config.headers.Authorization = authToken
}
if (companyId && !isAdminMode) {
config.headers.company = companyId
}
return config
})
export { client }

View File

@@ -0,0 +1,164 @@
export const API = {
// Authentication & Password Reset
LOGIN: '/api/v1/auth/login',
LOGOUT: '/api/v1/auth/logout',
FORGOT_PASSWORD: '/api/v1/auth/password/email',
RESET_PASSWORD: '/api/v1/auth/reset/password',
AUTH_CHECK: '/api/v1/auth/check',
CSRF_COOKIE: '/sanctum/csrf-cookie',
REGISTER_WITH_INVITATION: '/api/v1/auth/register-with-invitation',
// Invitation Registration (public)
INVITATION_DETAILS: '/api/v1/invitations', // append /{token}/details
// Invitations (user-scoped)
INVITATIONS_PENDING: '/api/v1/invitations/pending',
INVITATIONS: '/api/v1/invitations', // append /{token}/accept or /{token}/decline
// Bootstrap & General
BOOTSTRAP: '/api/v1/bootstrap',
CONFIG: '/api/v1/config',
CURRENT_COMPANY: '/api/v1/current-company',
SEARCH: '/api/v1/search',
SEARCH_USERS: '/api/v1/search/user',
APP_VERSION: '/api/v1/app/version',
COUNTRIES: '/api/v1/countries',
// Dashboard
DASHBOARD: '/api/v1/dashboard',
// Customers
CUSTOMERS: '/api/v1/customers',
CUSTOMERS_DELETE: '/api/v1/customers/delete',
CUSTOMER_STATS: '/api/v1/customers', // append /{id}/stats
// Items & Units
ITEMS: '/api/v1/items',
ITEMS_DELETE: '/api/v1/items/delete',
UNITS: '/api/v1/units',
// Invoices
INVOICES: '/api/v1/invoices',
INVOICES_DELETE: '/api/v1/invoices/delete',
INVOICE_TEMPLATES: '/api/v1/invoices/templates',
// Recurring Invoices
RECURRING_INVOICES: '/api/v1/recurring-invoices',
RECURRING_INVOICES_DELETE: '/api/v1/recurring-invoices/delete',
RECURRING_INVOICE_FREQUENCY: '/api/v1/recurring-invoice-frequency',
// Estimates
ESTIMATES: '/api/v1/estimates',
ESTIMATES_DELETE: '/api/v1/estimates/delete',
ESTIMATE_TEMPLATES: '/api/v1/estimates/templates',
// Expenses
EXPENSES: '/api/v1/expenses',
EXPENSES_DELETE: '/api/v1/expenses/delete',
// Expense Categories
CATEGORIES: '/api/v1/categories',
// Payments
PAYMENTS: '/api/v1/payments',
PAYMENTS_DELETE: '/api/v1/payments/delete',
PAYMENT_METHODS: '/api/v1/payment-methods',
// Custom Fields
CUSTOM_FIELDS: '/api/v1/custom-fields',
// Notes
NOTES: '/api/v1/notes',
// Tax Types
TAX_TYPES: '/api/v1/tax-types',
// Roles & Abilities
ROLES: '/api/v1/roles',
ABILITIES: '/api/v1/abilities',
// Company
COMPANY: '/api/v1/company',
COMPANY_UPLOAD_LOGO: '/api/v1/company/upload-logo',
COMPANY_SETTINGS: '/api/v1/company/settings',
COMPANY_HAS_TRANSACTIONS: '/api/v1/company/has-transactions',
COMPANIES: '/api/v1/companies',
COMPANIES_DELETE: '/api/v1/companies/delete',
TRANSFER_OWNERSHIP: '/api/v1/transfer/ownership', // append /{userId}
// Company Invitations (company-scoped)
COMPANY_INVITATIONS: '/api/v1/company-invitations',
// Members
MEMBERS: '/api/v1/members',
MEMBERS_DELETE: '/api/v1/members/delete',
// User Profile & Settings
ME: '/api/v1/me',
ME_SETTINGS: '/api/v1/me/settings',
ME_UPLOAD_AVATAR: '/api/v1/me/upload-avatar',
// Global Settings (admin)
SETTINGS: '/api/v1/settings',
// Mail Configuration (global)
MAIL_DRIVERS: '/api/v1/mail/drivers',
MAIL_CONFIG: '/api/v1/mail/config',
MAIL_TEST: '/api/v1/mail/test',
// Company Mail Configuration
COMPANY_MAIL_DEFAULT_CONFIG: '/api/v1/company/mail/config',
COMPANY_MAIL_CONFIG: '/api/v1/company/mail/company-config',
COMPANY_MAIL_TEST: '/api/v1/company/mail/company-test',
// PDF Configuration
PDF_DRIVERS: '/api/v1/pdf/drivers',
PDF_CONFIG: '/api/v1/pdf/config',
// Disks & Backups
DISKS: '/api/v1/disks',
DISK_DRIVERS: '/api/v1/disk/drivers',
BACKUPS: '/api/v1/backups',
DOWNLOAD_BACKUP: '/api/v1/download-backup',
// Exchange Rates & Currencies
CURRENCIES: '/api/v1/currencies',
CURRENCIES_USED: '/api/v1/currencies/used',
CURRENCIES_BULK_UPDATE: '/api/v1/currencies/bulk-update-exchange-rate',
EXCHANGE_RATE_PROVIDERS: '/api/v1/exchange-rate-providers',
USED_CURRENCIES: '/api/v1/used-currencies',
SUPPORTED_CURRENCIES: '/api/v1/supported-currencies',
// Serial Numbers
NEXT_NUMBER: '/api/v1/next-number',
NUMBER_PLACEHOLDERS: '/api/v1/number-placeholders',
// Formats
TIMEZONES: '/api/v1/timezones',
DATE_FORMATS: '/api/v1/date/formats',
TIME_FORMATS: '/api/v1/time/formats',
// Modules
MODULES: '/api/v1/modules',
MODULES_CHECK: '/api/v1/modules/check',
MODULES_DOWNLOAD: '/api/v1/modules/download',
MODULES_UPLOAD: '/api/v1/modules/upload',
MODULES_UNZIP: '/api/v1/modules/unzip',
MODULES_COPY: '/api/v1/modules/copy',
MODULES_COMPLETE: '/api/v1/modules/complete',
// Self Update
CHECK_UPDATE: '/api/v1/check/update',
UPDATE_DOWNLOAD: '/api/v1/update/download',
UPDATE_UNZIP: '/api/v1/update/unzip',
UPDATE_COPY: '/api/v1/update/copy',
UPDATE_DELETE: '/api/v1/update/delete',
UPDATE_MIGRATE: '/api/v1/update/migrate',
UPDATE_FINISH: '/api/v1/update/finish',
// Super Admin
SUPER_ADMIN_DASHBOARD: '/api/v1/super-admin/dashboard',
SUPER_ADMIN_COMPANIES: '/api/v1/super-admin/companies',
SUPER_ADMIN_USERS: '/api/v1/super-admin/users',
SUPER_ADMIN_STOP_IMPERSONATING: '/api/v1/super-admin/stop-impersonating',
} as const

View File

@@ -0,0 +1,123 @@
export { client } from './client'
export { API } from './endpoints'
export {
authService,
bootstrapService,
invoiceService,
estimateService,
recurringInvoiceService,
customerService,
paymentService,
expenseService,
itemService,
companyService,
userService,
memberService,
settingService,
dashboardService,
reportService,
roleService,
taxTypeService,
customFieldService,
noteService,
exchangeRateService,
moduleService,
backupService,
mailService,
pdfService,
diskService,
} from './services'
// Re-export all service types
export type {
LoginPayload,
LoginResponse,
ForgotPasswordPayload,
ResetPasswordPayload,
RegisterWithInvitationPayload,
BootstrapResponse,
MenuItem,
CurrentCompanyResponse,
InvoiceListParams,
InvoiceListResponse,
SendInvoicePayload,
InvoiceStatusPayload,
InvoiceTemplatesResponse,
EstimateListParams,
EstimateListResponse,
SendEstimatePayload,
EstimateStatusPayload,
EstimateTemplatesResponse,
RecurringInvoiceListParams,
RecurringInvoiceListResponse,
FrequencyDateParams,
FrequencyDateResponse,
CustomerListParams,
CustomerListResponse,
CustomerStatsData,
PaymentListParams,
PaymentListResponse,
SendPaymentPayload,
CreatePaymentMethodPayload,
ExpenseListParams,
ExpenseListResponse,
CreateExpenseCategoryPayload,
ItemListParams,
ItemListResponse,
CreateItemPayload,
CreateUnitPayload,
UpdateCompanyPayload,
CompanySettingsPayload,
CreateCompanyPayload,
UpdateProfilePayload,
UserSettingsPayload,
MemberListParams,
MemberListResponse,
UpdateMemberPayload,
InviteMemberPayload,
DeleteMembersPayload,
ConfigResponse,
GlobalSettingsPayload,
DateFormat,
TimeFormat,
DashboardParams,
DashboardResponse,
ChartData,
ReportParams,
SalesReportResponse,
ProfitLossReportResponse,
ExpenseReportResponse,
TaxReportResponse,
CreateRolePayload,
AbilitiesResponse,
CreateTaxTypePayload,
CustomFieldListParams,
CreateCustomFieldPayload,
CreateNotePayload,
CreateExchangeRateProviderPayload,
BulkUpdatePayload,
ExchangeRateResponse,
ActiveProviderResponse,
Module,
ModuleInstallPayload,
ModuleCheckResponse,
Backup,
CreateBackupPayload,
DeleteBackupParams,
MailConfig,
MailConfigResponse,
MailDriver,
SmtpConfig,
MailgunConfig,
SesConfig,
TestMailPayload,
PdfConfig,
PdfConfigResponse,
PdfDriver,
DomPdfConfig,
GotenbergConfig,
Disk,
DiskDriversResponse,
CreateDiskPayload,
} from './services'

View File

@@ -0,0 +1,81 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { User } from '../../types/domain/user'
import type { ApiResponse } from '../../types/api'
export interface LoginPayload {
email: string
password: string
remember?: boolean
}
export interface LoginResponse {
token: string
user: User
}
export interface ForgotPasswordPayload {
email: string
}
export interface ResetPasswordPayload {
email: string
password: string
password_confirmation: string
token: string
}
export interface InvitationDetails {
email: string
company_name: string
invited_by: string
}
export interface RegisterWithInvitationPayload {
name: string
email: string
password: string
password_confirmation: string
token: string
}
export const authService = {
async refreshCsrfCookie(): Promise<void> {
await client.get(API.CSRF_COOKIE)
},
async login(payload: LoginPayload): Promise<ApiResponse<LoginResponse>> {
await client.get(API.CSRF_COOKIE)
const { data } = await client.post(API.LOGIN, payload)
return data
},
async logout(): Promise<void> {
await client.post(API.LOGOUT)
},
async forgotPassword(payload: ForgotPasswordPayload): Promise<ApiResponse<{ success: boolean }>> {
const { data } = await client.post(API.FORGOT_PASSWORD, payload)
return data
},
async resetPassword(payload: ResetPasswordPayload): Promise<ApiResponse<{ success: boolean }>> {
const { data } = await client.post(API.RESET_PASSWORD, payload)
return data
},
async check(): Promise<ApiResponse<{ success: boolean }>> {
const { data } = await client.get(API.AUTH_CHECK)
return data
},
async getInvitationDetails(token: string): Promise<ApiResponse<InvitationDetails>> {
const { data } = await client.get(`${API.INVITATION_DETAILS}/${token}/details`)
return data
},
async registerWithInvitation(payload: RegisterWithInvitationPayload): Promise<ApiResponse<User>> {
const { data } = await client.post(API.REGISTER_WITH_INVITATION, payload)
return data
},
}

View File

@@ -0,0 +1,47 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { ApiResponse, ListParams } from '../../types/api'
export interface Backup {
id: number
disk: string
path: string
created_at: string
file_size: string
}
export interface CreateBackupPayload {
option: 'full' | 'database' | 'files'
selected_disk: string | null
}
export interface DeleteBackupParams {
disk: string
path?: string
file_name?: string
}
export const backupService = {
async list(params?: ListParams): Promise<ApiResponse<Backup[]>> {
const { data } = await client.get(API.BACKUPS, { params })
return data
},
async create(payload: CreateBackupPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.BACKUPS, payload)
return data
},
async delete(params: DeleteBackupParams): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.BACKUPS}/${params.disk}`, { params })
return data
},
async download(params: { disk: string; path?: string; file_name?: string }): Promise<Blob> {
const { data } = await client.get(API.DOWNLOAD_BACKUP, {
params,
responseType: 'blob',
})
return data
},
}

View File

@@ -0,0 +1,53 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { User, UserSetting } from '../../types/domain/user'
import type { Company } from '../../types/domain/company'
import type { Currency } from '../../types/domain/currency'
import type { Ability } from '../../types/domain/role'
export interface MenuItem {
title: string
name: string
route: string
icon: string
group: string
ability?: string
}
export interface BootstrapResponse {
current_user: User
current_user_settings: Record<string, string>
current_user_abilities: Ability[]
companies: Company[]
current_company: Company | null
current_company_settings: Record<string, string>
current_company_currency: Currency | null
main_menu: MenuItem[]
setting_menu: MenuItem[]
config: Record<string, unknown>
global_settings: Record<string, string>
modules: string[]
pending_invitations?: Array<{
token: string
company_name: string
invited_by: string
email: string
}>
}
export interface CurrentCompanyResponse {
data: Company
}
export const bootstrapService = {
async bootstrap(adminMode?: boolean): Promise<BootstrapResponse> {
const url = adminMode ? `${API.BOOTSTRAP}?admin_mode=1` : API.BOOTSTRAP
const { data } = await client.get(url)
return data
},
async getCurrentCompany(): Promise<CurrentCompanyResponse> {
const { data } = await client.get(API.CURRENT_COMPANY)
return data
},
}

View File

@@ -0,0 +1,100 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Company } from '../../types/domain/company'
import type { ApiResponse } from '../../types/api'
export interface UpdateCompanyPayload {
name: string
vat_id?: string | null
tax_id?: string | null
phone?: string | null
address?: {
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
}
}
export interface CompanySettingsPayload {
settings: Record<string, string | number | boolean | null>
}
export interface CreateCompanyPayload {
name: string
currency?: number
address?: Record<string, unknown>
}
export const companyService = {
async update(payload: UpdateCompanyPayload): Promise<ApiResponse<Company>> {
const { data } = await client.put(API.COMPANY, payload)
return data
},
async uploadLogo(payload: FormData): Promise<ApiResponse<Company>> {
const { data } = await client.post(API.COMPANY_UPLOAD_LOGO, payload)
return data
},
async getSettings(settings?: string[]): Promise<Record<string, string>> {
const { data } = await client.get(API.COMPANY_SETTINGS, {
params: { settings },
})
return data
},
async updateSettings(payload: CompanySettingsPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.COMPANY_SETTINGS, payload)
return data
},
async hasTransactions(): Promise<{ has_transactions: boolean }> {
const { data } = await client.get(API.COMPANY_HAS_TRANSACTIONS)
return data
},
async create(payload: CreateCompanyPayload): Promise<ApiResponse<Company>> {
const { data } = await client.post(API.COMPANIES, payload)
return data
},
async listUserCompanies(): Promise<ApiResponse<Company[]>> {
const { data } = await client.get(API.COMPANIES)
return data
},
async delete(payload: { id: number }): Promise<{ success: boolean }> {
const { data } = await client.post(API.COMPANIES_DELETE, payload)
return data
},
async transferOwnership(userId: number): Promise<{ success: boolean }> {
const { data } = await client.post(`${API.TRANSFER_OWNERSHIP}/${userId}`)
return data
},
// Company Mail Configuration
async getMailDefaultConfig(): Promise<Record<string, unknown>> {
const { data } = await client.get(API.COMPANY_MAIL_DEFAULT_CONFIG)
return data
},
async getMailConfig(): Promise<Record<string, unknown>> {
const { data } = await client.get(API.COMPANY_MAIL_CONFIG)
return data
},
async saveMailConfig(payload: Record<string, unknown>): Promise<{ success: boolean }> {
const { data } = await client.post(API.COMPANY_MAIL_CONFIG, payload)
return data
},
async testMailConfig(payload: Record<string, unknown>): Promise<{ success: boolean }> {
const { data } = await client.post(API.COMPANY_MAIL_TEST, payload)
return data
},
}

View File

@@ -0,0 +1,47 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { CustomField } from '../../types/domain/custom-field'
import type { ApiResponse, ListParams } from '../../types/api'
export interface CustomFieldListParams extends ListParams {
model_type?: string
type?: string
}
export interface CreateCustomFieldPayload {
name: string
label: string
model_type: string
type: string
placeholder?: string | null
is_required?: boolean
options?: Array<{ name: string }> | string[] | null
order?: number | null
}
export const customFieldService = {
async list(params?: CustomFieldListParams): Promise<ApiResponse<CustomField[]>> {
const { data } = await client.get(API.CUSTOM_FIELDS, { params })
return data
},
async get(id: number): Promise<ApiResponse<CustomField>> {
const { data } = await client.get(`${API.CUSTOM_FIELDS}/${id}`)
return data
},
async create(payload: CreateCustomFieldPayload): Promise<ApiResponse<CustomField>> {
const { data } = await client.post(API.CUSTOM_FIELDS, payload)
return data
},
async update(id: number, payload: Partial<CreateCustomFieldPayload>): Promise<ApiResponse<CustomField>> {
const { data } = await client.put(`${API.CUSTOM_FIELDS}/${id}`, payload)
return data
},
async delete(id: number): Promise<{ success: boolean; error?: string }> {
const { data } = await client.delete(`${API.CUSTOM_FIELDS}/${id}`)
return data
},
}

View File

@@ -0,0 +1,69 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Customer, CreateCustomerPayload } from '../../types/domain/customer'
import type {
ApiResponse,
ListParams,
DeletePayload,
} from '../../types/api'
export interface CustomerListParams extends ListParams {
display_name?: string
}
export interface CustomerListMeta {
current_page: number
last_page: number
per_page: number
total: number
customer_total_count: number
}
export interface CustomerListResponse {
data: Customer[]
meta: CustomerListMeta
}
export interface CustomerStatsData {
id: number
name: string
email: string | null
total_invoices: number
total_estimates: number
total_payments: number
total_expenses: number
total_amount_due: number
total_paid: number
}
export const customerService = {
async list(params?: CustomerListParams): Promise<CustomerListResponse> {
const { data } = await client.get(API.CUSTOMERS, { params })
return data
},
async get(id: number): Promise<ApiResponse<Customer>> {
const { data } = await client.get(`${API.CUSTOMERS}/${id}`)
return data
},
async create(payload: CreateCustomerPayload): Promise<ApiResponse<Customer>> {
const { data } = await client.post(API.CUSTOMERS, payload)
return data
},
async update(id: number, payload: Partial<CreateCustomerPayload>): Promise<ApiResponse<Customer>> {
const { data } = await client.put(`${API.CUSTOMERS}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.CUSTOMERS_DELETE, payload)
return data
},
async getStats(id: number, params?: Record<string, unknown>): Promise<ApiResponse<CustomerStatsData>> {
const { data } = await client.get(`${API.CUSTOMER_STATS}/${id}/stats`, { params })
return data
},
}

View File

@@ -0,0 +1,53 @@
import { client } from '../client'
import { API } from '../endpoints'
export interface DashboardParams {
previous_year?: number
}
export interface ChartData {
months: string[]
invoice_totals: number[]
expense_totals: number[]
receipt_totals: number[]
net_income_totals: number[]
}
export interface DashboardResponse {
total_amount_due: number
total_customer_count: number
total_invoice_count: number
total_estimate_count: number
chart_data: ChartData
total_sales: string
total_receipts: string
total_expenses: string
total_net_income: string
recent_due_invoices: Array<{
id: number
invoice_number: string
due_amount: number
formatted_due_date: string
customer?: {
id: number
name: string
}
}>
recent_estimates: Array<{
id: number
estimate_number: string
total: number
status: string
customer?: {
id: number
name: string
}
}>
}
export const dashboardService = {
async load(params?: DashboardParams): Promise<DashboardResponse> {
const { data } = await client.get(API.DASHBOARD, { params })
return data
},
}

View File

@@ -0,0 +1,65 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { ApiResponse, ListParams } from '../../types/api'
export interface Disk {
id: number
name: string
driver: string
set_as_default: boolean
credentials: Record<string, string>
created_at: string
updated_at: string
}
export interface DiskDriversResponse {
drivers: string[]
[key: string]: unknown
}
export interface CreateDiskPayload {
name: string
selected_driver: string
// S3/S3-compat/DOSpaces fields
key?: string
secret?: string
region?: string
bucket?: string
root?: string
endpoint?: string
// Dropbox fields
token?: string
app?: string
}
export const diskService = {
async list(params?: ListParams): Promise<ApiResponse<Disk[]>> {
const { data } = await client.get(API.DISKS, { params })
return data
},
async get(disk: string): Promise<Record<string, unknown>> {
const { data } = await client.get(`${API.DISKS}/${disk}`)
return data
},
async create(payload: CreateDiskPayload): Promise<Disk> {
const { data } = await client.post(API.DISKS, payload)
return data
},
async update(id: number, payload: Partial<CreateDiskPayload>): Promise<ApiResponse<Disk>> {
const { data } = await client.put(`${API.DISKS}/${id}`, payload)
return data
},
async delete(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.DISKS}/${id}`)
return data
},
async getDrivers(): Promise<DiskDriversResponse> {
const { data } = await client.get(API.DISK_DRIVERS)
return data
},
}

View File

@@ -0,0 +1,114 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Estimate, CreateEstimatePayload } from '../../types/domain/estimate'
import type { Invoice } from '../../types/domain/invoice'
import type {
ApiResponse,
ListParams,
DateRangeParams,
NextNumberResponse,
DeletePayload,
} from '../../types/api'
export interface EstimateListParams extends ListParams, DateRangeParams {
status?: string
customer_id?: number
}
export interface EstimateListMeta {
current_page: number
last_page: number
per_page: number
total: number
estimate_total_count: number
}
export interface EstimateListResponse {
data: Estimate[]
meta: EstimateListMeta
}
export interface SendEstimatePayload {
id: number
subject?: string
body?: string
from?: string
to?: string
is_preview?: boolean
}
export interface EstimateStatusPayload {
id: number
status: string
}
export interface EstimateTemplate {
name: string
path: string
}
export interface EstimateTemplatesResponse {
estimateTemplates: EstimateTemplate[]
}
export const estimateService = {
async list(params?: EstimateListParams): Promise<EstimateListResponse> {
const { data } = await client.get(API.ESTIMATES, { params })
return data
},
async get(id: number): Promise<ApiResponse<Estimate>> {
const { data } = await client.get(`${API.ESTIMATES}/${id}`)
return data
},
async create(payload: CreateEstimatePayload): Promise<ApiResponse<Estimate>> {
const { data } = await client.post(API.ESTIMATES, payload)
return data
},
async update(id: number, payload: Partial<CreateEstimatePayload>): Promise<ApiResponse<Estimate>> {
const { data } = await client.put(`${API.ESTIMATES}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.ESTIMATES_DELETE, payload)
return data
},
async send(payload: SendEstimatePayload): Promise<ApiResponse<Estimate>> {
const { data } = await client.post(`${API.ESTIMATES}/${payload.id}/send`, payload)
return data
},
async sendPreview(id: number, params?: Record<string, unknown>): Promise<ApiResponse<string>> {
const { data } = await client.get(`${API.ESTIMATES}/${id}/send/preview`, { params })
return data
},
async clone(id: number): Promise<ApiResponse<Estimate>> {
const { data } = await client.post(`${API.ESTIMATES}/${id}/clone`)
return data
},
async changeStatus(payload: EstimateStatusPayload): Promise<ApiResponse<Estimate>> {
const { data } = await client.post(`${API.ESTIMATES}/${payload.id}/status`, payload)
return data
},
async convertToInvoice(id: number): Promise<ApiResponse<Invoice>> {
const { data } = await client.post(`${API.ESTIMATES}/${id}/convert-to-invoice`)
return data
},
async getNextNumber(params?: { key?: string }): Promise<NextNumberResponse> {
const { data } = await client.get(API.NEXT_NUMBER, { params: { key: 'estimate', ...params } })
return data
},
async getTemplates(): Promise<EstimateTemplatesResponse> {
const { data } = await client.get(API.ESTIMATE_TEMPLATES)
return data
},
}

View File

@@ -0,0 +1,117 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { ExchangeRateProvider, Currency } from '../../types/domain/currency'
import type { ApiResponse, ListParams } from '../../types/api'
export interface CreateExchangeRateProviderPayload {
driver: string
key: string
active?: boolean
currencies?: string[]
}
export interface ExchangeRateResponse {
exchange_rate: number
}
export interface ActiveProviderResponse {
has_active_provider: boolean
exchange_rate: number | null
}
export interface SupportedCurrenciesResponse {
supportedCurrencies: string[]
}
export interface UsedCurrenciesResponse {
activeUsedCurrencies: Currency[]
}
export interface BulkCurrenciesResponse {
currencies: Array<Currency & { exchange_rate: number | null }>
}
export interface BulkUpdatePayload {
currencies: Array<{
id: number
exchange_rate: number
}>
}
export interface ConfigDriversResponse {
exchange_rate_drivers: string[]
}
export const exchangeRateService = {
// Providers CRUD
async listProviders(params?: ListParams): Promise<ApiResponse<ExchangeRateProvider[]>> {
const { data } = await client.get(API.EXCHANGE_RATE_PROVIDERS, { params })
return data
},
async getProvider(id: number): Promise<ApiResponse<ExchangeRateProvider>> {
const { data } = await client.get(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`)
return data
},
async createProvider(payload: CreateExchangeRateProviderPayload): Promise<ApiResponse<ExchangeRateProvider>> {
const { data } = await client.post(API.EXCHANGE_RATE_PROVIDERS, payload)
return data
},
async updateProvider(
id: number,
payload: Partial<CreateExchangeRateProviderPayload>,
): Promise<ApiResponse<ExchangeRateProvider>> {
const { data } = await client.put(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`, payload)
return data
},
async deleteProvider(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`)
return data
},
// Exchange Rates
async getRate(currencyId: number): Promise<ExchangeRateResponse> {
const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/exchange-rate`)
return data
},
async getActiveProvider(currencyId: number): Promise<ActiveProviderResponse> {
const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/active-provider`)
return data
},
// Currency lists
async getSupportedCurrencies(): Promise<SupportedCurrenciesResponse> {
const { data } = await client.get(API.SUPPORTED_CURRENCIES)
return data
},
async getUsedCurrencies(): Promise<UsedCurrenciesResponse> {
const { data } = await client.get(API.USED_CURRENCIES)
return data
},
async getBulkCurrencies(): Promise<BulkCurrenciesResponse> {
const { data } = await client.get(API.CURRENCIES_USED)
return data
},
async bulkUpdateExchangeRate(payload: BulkUpdatePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.CURRENCIES_BULK_UPDATE, payload)
return data
},
// Config
async getDrivers(): Promise<ConfigDriversResponse> {
const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } })
return data
},
async getCurrencyConverterServers(): Promise<Record<string, unknown>> {
const { data } = await client.get(API.CONFIG, { params: { key: 'currency_converter_servers' } })
return data
},
}

View File

@@ -0,0 +1,100 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Expense, ExpenseCategory, CreateExpensePayload } from '../../types/domain/expense'
import type {
ApiResponse,
ListParams,
DateRangeParams,
DeletePayload,
} from '../../types/api'
export interface ExpenseListParams extends ListParams, DateRangeParams {
expense_category_id?: number
customer_id?: number
}
export interface ExpenseListMeta {
current_page: number
last_page: number
per_page: number
total: number
expense_total_count: number
}
export interface ExpenseListResponse {
data: Expense[]
meta: ExpenseListMeta
}
export interface CreateExpenseCategoryPayload {
name: string
description?: string | null
}
export const expenseService = {
async list(params?: ExpenseListParams): Promise<ExpenseListResponse> {
const { data } = await client.get(API.EXPENSES, { params })
return data
},
async get(id: number): Promise<ApiResponse<Expense>> {
const { data } = await client.get(`${API.EXPENSES}/${id}`)
return data
},
async create(payload: FormData): Promise<ApiResponse<Expense>> {
const { data } = await client.post(API.EXPENSES, payload)
return data
},
async update(id: number, payload: FormData): Promise<ApiResponse<Expense>> {
const { data } = await client.post(`${API.EXPENSES}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.EXPENSES_DELETE, payload)
return data
},
async showReceipt(id: number): Promise<Blob> {
const { data } = await client.get(`${API.EXPENSES}/${id}/show/receipt`, {
responseType: 'blob',
})
return data
},
async uploadReceipt(id: number, payload: FormData): Promise<ApiResponse<Expense>> {
const { data } = await client.post(`${API.EXPENSES}/${id}/upload/receipts`, payload)
return data
},
// Expense Categories
async listCategories(params?: ListParams): Promise<ApiResponse<ExpenseCategory[]>> {
const { data } = await client.get(API.CATEGORIES, { params })
return data
},
async getCategory(id: number): Promise<ApiResponse<ExpenseCategory>> {
const { data } = await client.get(`${API.CATEGORIES}/${id}`)
return data
},
async createCategory(payload: CreateExpenseCategoryPayload): Promise<ApiResponse<ExpenseCategory>> {
const { data } = await client.post(API.CATEGORIES, payload)
return data
},
async updateCategory(
id: number,
payload: CreateExpenseCategoryPayload,
): Promise<ApiResponse<ExpenseCategory>> {
const { data } = await client.put(`${API.CATEGORIES}/${id}`, payload)
return data
},
async deleteCategory(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.CATEGORIES}/${id}`)
return data
},
}

View File

@@ -0,0 +1,52 @@
export { authService } from './auth.service'
export { bootstrapService } from './bootstrap.service'
export { invoiceService } from './invoice.service'
export { estimateService } from './estimate.service'
export { recurringInvoiceService } from './recurring-invoice.service'
export { customerService } from './customer.service'
export { paymentService } from './payment.service'
export { expenseService } from './expense.service'
export { itemService } from './item.service'
export { companyService } from './company.service'
export { userService } from './user.service'
export { memberService } from './member.service'
export { settingService } from './setting.service'
export { dashboardService } from './dashboard.service'
export { reportService } from './report.service'
export { roleService } from './role.service'
export { taxTypeService } from './tax-type.service'
export { customFieldService } from './custom-field.service'
export { noteService } from './note.service'
export { exchangeRateService } from './exchange-rate.service'
export { moduleService } from './module.service'
export { backupService } from './backup.service'
export { mailService } from './mail.service'
export { pdfService } from './pdf.service'
export { diskService } from './disk.service'
// Re-export service types for convenience
export type { LoginPayload, LoginResponse, ForgotPasswordPayload, ResetPasswordPayload, RegisterWithInvitationPayload } from './auth.service'
export type { BootstrapResponse, MenuItem, CurrentCompanyResponse } from './bootstrap.service'
export type { InvoiceListParams, InvoiceListResponse, SendInvoicePayload, InvoiceStatusPayload, InvoiceTemplatesResponse } from './invoice.service'
export type { EstimateListParams, EstimateListResponse, SendEstimatePayload, EstimateStatusPayload, EstimateTemplatesResponse } from './estimate.service'
export type { RecurringInvoiceListParams, RecurringInvoiceListResponse, FrequencyDateParams, FrequencyDateResponse } from './recurring-invoice.service'
export type { CustomerListParams, CustomerListResponse, CustomerStatsData } from './customer.service'
export type { PaymentListParams, PaymentListResponse, SendPaymentPayload, CreatePaymentMethodPayload } from './payment.service'
export type { ExpenseListParams, ExpenseListResponse, CreateExpenseCategoryPayload } from './expense.service'
export type { ItemListParams, ItemListResponse, CreateItemPayload, CreateUnitPayload } from './item.service'
export type { UpdateCompanyPayload, CompanySettingsPayload, CreateCompanyPayload } from './company.service'
export type { UpdateProfilePayload, UserSettingsPayload } from './user.service'
export type { MemberListParams, MemberListResponse, UpdateMemberPayload, InviteMemberPayload, DeleteMembersPayload } from './member.service'
export type { ConfigResponse, GlobalSettingsPayload, DateFormat, TimeFormat } from './setting.service'
export type { DashboardParams, DashboardResponse, ChartData } from './dashboard.service'
export type { ReportParams, SalesReportResponse, ProfitLossReportResponse, ExpenseReportResponse, TaxReportResponse } from './report.service'
export type { CreateRolePayload, AbilitiesResponse } from './role.service'
export type { CreateTaxTypePayload } from './tax-type.service'
export type { CustomFieldListParams, CreateCustomFieldPayload } from './custom-field.service'
export type { CreateNotePayload } from './note.service'
export type { CreateExchangeRateProviderPayload, BulkUpdatePayload, ExchangeRateResponse, ActiveProviderResponse } from './exchange-rate.service'
export type { Module, ModuleInstallPayload, ModuleCheckResponse } from './module.service'
export type { Backup, CreateBackupPayload, DeleteBackupParams } from './backup.service'
export type { MailConfig, MailConfigResponse, MailDriver, SmtpConfig, MailgunConfig, SesConfig, TestMailPayload } from './mail.service'
export type { PdfConfig, PdfConfigResponse, PdfDriver, DomPdfConfig, GotenbergConfig } from './pdf.service'
export type { Disk, DiskDriversResponse, CreateDiskPayload } from './disk.service'

View File

@@ -0,0 +1,112 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Invoice, CreateInvoicePayload } from '../../types/domain/invoice'
import type {
ApiResponse,
PaginatedResponse,
ListParams,
DateRangeParams,
NextNumberResponse,
DeletePayload,
} from '../../types/api'
export interface InvoiceListParams extends ListParams, DateRangeParams {
status?: string
customer_id?: number
}
export interface InvoiceListMeta {
current_page: number
last_page: number
per_page: number
total: number
invoice_total_count: number
}
export interface InvoiceListResponse {
data: Invoice[]
meta: InvoiceListMeta
}
export interface SendInvoicePayload {
id: number
subject?: string
body?: string
from?: string
to?: string
}
export interface InvoiceStatusPayload {
id: number
status: string
}
export interface SendPreviewParams {
id: number
}
export interface InvoiceTemplate {
name: string
path: string
}
export interface InvoiceTemplatesResponse {
invoiceTemplates: InvoiceTemplate[]
}
export const invoiceService = {
async list(params?: InvoiceListParams): Promise<InvoiceListResponse> {
const { data } = await client.get(API.INVOICES, { params })
return data
},
async get(id: number): Promise<ApiResponse<Invoice>> {
const { data } = await client.get(`${API.INVOICES}/${id}`)
return data
},
async create(payload: CreateInvoicePayload): Promise<ApiResponse<Invoice>> {
const { data } = await client.post(API.INVOICES, payload)
return data
},
async update(id: number, payload: Partial<CreateInvoicePayload>): Promise<ApiResponse<Invoice>> {
const { data } = await client.put(`${API.INVOICES}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.INVOICES_DELETE, payload)
return data
},
async send(payload: SendInvoicePayload): Promise<ApiResponse<Invoice>> {
const { data } = await client.post(`${API.INVOICES}/${payload.id}/send`, payload)
return data
},
async sendPreview(params: SendPreviewParams): Promise<ApiResponse<string>> {
const { data } = await client.get(`${API.INVOICES}/${params.id}/send/preview`, { params })
return data
},
async clone(id: number): Promise<ApiResponse<Invoice>> {
const { data } = await client.post(`${API.INVOICES}/${id}/clone`)
return data
},
async changeStatus(payload: InvoiceStatusPayload): Promise<ApiResponse<Invoice>> {
const { data } = await client.post(`${API.INVOICES}/${payload.id}/status`, payload)
return data
},
async getNextNumber(params?: { key?: string }): Promise<NextNumberResponse> {
const { data } = await client.get(API.NEXT_NUMBER, { params: { key: 'invoice', ...params } })
return data
},
async getTemplates(): Promise<InvoiceTemplatesResponse> {
const { data } = await client.get(API.INVOICE_TEMPLATES)
return data
},
}

View File

@@ -0,0 +1,90 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Item, Unit } from '../../types/domain/item'
import type {
ApiResponse,
ListParams,
DeletePayload,
} from '../../types/api'
export interface ItemListParams extends ListParams {
filter?: Record<string, unknown>
}
export interface ItemListMeta {
current_page: number
last_page: number
per_page: number
total: number
item_total_count: number
}
export interface ItemListResponse {
data: Item[]
meta: ItemListMeta
}
export interface CreateItemPayload {
name: string
description?: string | null
price: number
unit_id?: number | null
taxes?: Array<{ tax_type_id: number }>
}
export interface CreateUnitPayload {
name: string
}
export const itemService = {
async list(params?: ItemListParams): Promise<ItemListResponse> {
const { data } = await client.get(API.ITEMS, { params })
return data
},
async get(id: number): Promise<ApiResponse<Item>> {
const { data } = await client.get(`${API.ITEMS}/${id}`)
return data
},
async create(payload: CreateItemPayload): Promise<ApiResponse<Item>> {
const { data } = await client.post(API.ITEMS, payload)
return data
},
async update(id: number, payload: Partial<CreateItemPayload>): Promise<ApiResponse<Item>> {
const { data } = await client.put(`${API.ITEMS}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.ITEMS_DELETE, payload)
return data
},
// Units
async listUnits(params?: ListParams): Promise<ApiResponse<Unit[]>> {
const { data } = await client.get(API.UNITS, { params })
return data
},
async getUnit(id: number): Promise<ApiResponse<Unit>> {
const { data } = await client.get(`${API.UNITS}/${id}`)
return data
},
async createUnit(payload: CreateUnitPayload): Promise<ApiResponse<Unit>> {
const { data } = await client.post(API.UNITS, payload)
return data
},
async updateUnit(id: number, payload: CreateUnitPayload): Promise<ApiResponse<Unit>> {
const { data } = await client.put(`${API.UNITS}/${id}`, payload)
return data
},
async deleteUnit(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.UNITS}/${id}`)
return data
},
}

View File

@@ -0,0 +1,71 @@
import { client } from '../client'
import { API } from '../endpoints'
export interface MailDriver {
name: string
value: string
}
export interface SmtpConfig {
mail_driver: string
mail_host: string
mail_port: number | null
mail_username: string
mail_password: string
mail_encryption: string
from_mail: string
from_name: string
}
export interface MailgunConfig {
mail_driver: string
mail_mailgun_domain: string
mail_mailgun_secret: string
mail_mailgun_endpoint: string
from_mail: string
from_name: string
}
export interface SesConfig {
mail_driver: string
mail_host: string
mail_port: number | null
mail_ses_key: string
mail_ses_secret: string
mail_ses_region: string
from_mail: string
from_name: string
}
export type MailConfig = SmtpConfig | MailgunConfig | SesConfig
export interface MailConfigResponse {
mail_driver: string
[key: string]: unknown
}
export interface TestMailPayload {
to: string
}
export const mailService = {
async getDrivers(): Promise<MailDriver[]> {
const { data } = await client.get(API.MAIL_DRIVERS)
return data
},
async getConfig(): Promise<MailConfigResponse> {
const { data } = await client.get(API.MAIL_CONFIG)
return data
},
async saveConfig(payload: MailConfig): Promise<{ success?: string; error?: string }> {
const { data } = await client.post(API.MAIL_CONFIG, payload)
return data
},
async testEmail(payload: TestMailPayload): Promise<{ success?: boolean; error?: string }> {
const { data } = await client.post(API.MAIL_TEST, payload)
return data
},
}

View File

@@ -0,0 +1,102 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { User } from '../../types/domain/user'
import type { CompanyInvitation } from '../../types/domain/company'
import type { ApiResponse, PaginatedResponse, ListParams } from '../../types/api'
export interface MemberListParams extends ListParams {
display_name?: string
}
export interface MemberListResponse {
data: User[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
export interface UpdateMemberPayload {
name?: string
email?: string
phone?: string | null
role?: string | null
companies?: Array<{
id: number
role?: string
}>
}
export interface InviteMemberPayload {
email: string
role?: string
}
export interface DeleteMembersPayload {
users: number[]
}
export interface PendingInvitationsResponse {
invitations: CompanyInvitation[]
}
export const memberService = {
async list(params?: MemberListParams): Promise<MemberListResponse> {
const { data } = await client.get(API.MEMBERS, { params })
return data
},
async get(id: number): Promise<ApiResponse<User>> {
const { data } = await client.get(`${API.MEMBERS}/${id}`)
return data
},
async create(payload: UpdateMemberPayload): Promise<ApiResponse<User>> {
const { data } = await client.post(API.MEMBERS, payload)
return data
},
async update(id: number, payload: UpdateMemberPayload): Promise<ApiResponse<User>> {
const { data } = await client.put(`${API.MEMBERS}/${id}`, payload)
return data
},
async delete(payload: DeleteMembersPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.MEMBERS_DELETE, payload)
return data
},
// Company Invitations (send invitations)
async fetchPendingInvitations(): Promise<PendingInvitationsResponse> {
const { data } = await client.get(API.COMPANY_INVITATIONS)
return data
},
async invite(payload: InviteMemberPayload): Promise<ApiResponse<CompanyInvitation>> {
const { data } = await client.post(API.COMPANY_INVITATIONS, payload)
return data
},
async cancelInvitation(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.COMPANY_INVITATIONS}/${id}`)
return data
},
// User-scoped invitation responses
async fetchUserPendingInvitations(): Promise<PendingInvitationsResponse> {
const { data } = await client.get(API.INVITATIONS_PENDING)
return data
},
async acceptInvitation(token: string): Promise<{ success: boolean }> {
const { data } = await client.post(`${API.INVITATIONS}/${token}/accept`)
return data
},
async declineInvitation(token: string): Promise<{ success: boolean }> {
const { data } = await client.post(`${API.INVITATIONS}/${token}/decline`)
return data
},
}

View File

@@ -0,0 +1,77 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { ApiResponse } from '../../types/api'
export interface Module {
name: string
slug: string
description: string
version: string
enabled: boolean
installed: boolean
[key: string]: unknown
}
export interface ModuleCheckResponse {
error?: string
success?: boolean
}
export interface ModuleInstallPayload {
module: string
version: string
api_token?: string
}
export const moduleService = {
async list(): Promise<ApiResponse<Module[]>> {
const { data } = await client.get(API.MODULES)
return data
},
async get(module: string): Promise<Module> {
const { data } = await client.get(`${API.MODULES}/${module}`)
return data
},
async checkToken(apiToken: string): Promise<ModuleCheckResponse> {
const { data } = await client.get(`${API.MODULES_CHECK}?api_token=${apiToken}`)
return data
},
async enable(module: string): Promise<{ success: boolean }> {
const { data } = await client.post(`${API.MODULES}/${module}/enable`)
return data
},
async disable(module: string): Promise<{ success: boolean }> {
const { data } = await client.post(`${API.MODULES}/${module}/disable`)
return data
},
// Installation flow
async download(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.MODULES_DOWNLOAD, payload)
return data
},
async upload(payload: FormData): Promise<{ success: boolean }> {
const { data } = await client.post(API.MODULES_UPLOAD, payload)
return data
},
async unzip(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.MODULES_UNZIP, payload)
return data
},
async copy(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.MODULES_COPY, payload)
return data
},
async complete(payload: ModuleInstallPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.MODULES_COMPLETE, payload)
return data
},
}

View File

@@ -0,0 +1,38 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Note } from '../../types/domain/note'
import type { ApiResponse, ListParams } from '../../types/api'
export interface CreateNotePayload {
type: string
name: string
notes: string
is_default?: boolean
}
export const noteService = {
async list(params?: ListParams): Promise<ApiResponse<Note[]>> {
const { data } = await client.get(API.NOTES, { params })
return data
},
async get(id: number): Promise<ApiResponse<Note>> {
const { data } = await client.get(`${API.NOTES}/${id}`)
return data
},
async create(payload: CreateNotePayload): Promise<Note> {
const { data } = await client.post(API.NOTES, payload)
return data
},
async update(id: number, payload: Partial<CreateNotePayload>): Promise<ApiResponse<Note>> {
const { data } = await client.put(`${API.NOTES}/${id}`, payload)
return data
},
async delete(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.NOTES}/${id}`)
return data
},
}

View File

@@ -0,0 +1,108 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Payment, PaymentMethod, CreatePaymentPayload } from '../../types/domain/payment'
import type {
ApiResponse,
ListParams,
NextNumberResponse,
DeletePayload,
} from '../../types/api'
export interface PaymentListParams extends ListParams {
customer_id?: number
from_date?: string
to_date?: string
}
export interface PaymentListMeta {
current_page: number
last_page: number
per_page: number
total: number
payment_total_count: number
}
export interface PaymentListResponse {
data: Payment[]
meta: PaymentListMeta
}
export interface SendPaymentPayload {
id: number
subject?: string
body?: string
from?: string
to?: string
}
export interface CreatePaymentMethodPayload {
name: string
}
export const paymentService = {
async list(params?: PaymentListParams): Promise<PaymentListResponse> {
const { data } = await client.get(API.PAYMENTS, { params })
return data
},
async get(id: number): Promise<ApiResponse<Payment>> {
const { data } = await client.get(`${API.PAYMENTS}/${id}`)
return data
},
async create(payload: CreatePaymentPayload): Promise<ApiResponse<Payment>> {
const { data } = await client.post(API.PAYMENTS, payload)
return data
},
async update(id: number, payload: Partial<CreatePaymentPayload>): Promise<ApiResponse<Payment>> {
const { data } = await client.put(`${API.PAYMENTS}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.PAYMENTS_DELETE, payload)
return data
},
async send(payload: SendPaymentPayload): Promise<ApiResponse<Payment>> {
const { data } = await client.post(`${API.PAYMENTS}/${payload.id}/send`, payload)
return data
},
async sendPreview(id: number, params?: Record<string, unknown>): Promise<ApiResponse<string>> {
const { data } = await client.get(`${API.PAYMENTS}/${id}/send/preview`, { params })
return data
},
async getNextNumber(params?: { key?: string }): Promise<NextNumberResponse> {
const { data } = await client.get(API.NEXT_NUMBER, { params: { key: 'payment', ...params } })
return data
},
// Payment Methods
async listMethods(params?: ListParams): Promise<ApiResponse<PaymentMethod[]>> {
const { data } = await client.get(API.PAYMENT_METHODS, { params })
return data
},
async getMethod(id: number): Promise<ApiResponse<PaymentMethod>> {
const { data } = await client.get(`${API.PAYMENT_METHODS}/${id}`)
return data
},
async createMethod(payload: CreatePaymentMethodPayload): Promise<ApiResponse<PaymentMethod>> {
const { data } = await client.post(API.PAYMENT_METHODS, payload)
return data
},
async updateMethod(id: number, payload: CreatePaymentMethodPayload): Promise<ApiResponse<PaymentMethod>> {
const { data } = await client.put(`${API.PAYMENT_METHODS}/${id}`, payload)
return data
},
async deleteMethod(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.PAYMENT_METHODS}/${id}`)
return data
},
}

View File

@@ -0,0 +1,41 @@
import { client } from '../client'
import { API } from '../endpoints'
export interface PdfDriver {
name: string
value: string
}
export interface DomPdfConfig {
pdf_driver: string
}
export interface GotenbergConfig {
pdf_driver: string
gotenberg_host: string
gotenberg_papersize: string
}
export type PdfConfig = DomPdfConfig | GotenbergConfig
export interface PdfConfigResponse {
pdf_driver: string
[key: string]: unknown
}
export const pdfService = {
async getDrivers(): Promise<PdfDriver[]> {
const { data } = await client.get(API.PDF_DRIVERS)
return data
},
async getConfig(): Promise<PdfConfigResponse> {
const { data } = await client.get(API.PDF_CONFIG)
return data
},
async saveConfig(payload: PdfConfig): Promise<{ success?: string; error?: string }> {
const { data } = await client.post(API.PDF_CONFIG, payload)
return data
},
}

View File

@@ -0,0 +1,70 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { RecurringInvoice, CreateRecurringInvoicePayload } from '../../types/domain/recurring-invoice'
import type {
ApiResponse,
ListParams,
DeletePayload,
} from '../../types/api'
export interface RecurringInvoiceListParams extends ListParams {
status?: string
customer_id?: number
}
export interface RecurringInvoiceListMeta {
current_page: number
last_page: number
per_page: number
total: number
recurring_invoice_total_count: number
}
export interface RecurringInvoiceListResponse {
data: RecurringInvoice[]
meta: RecurringInvoiceListMeta
}
export interface FrequencyDateParams {
frequency: string
starts_at?: string
}
export interface FrequencyDateResponse {
next_invoice_at: string
}
export const recurringInvoiceService = {
async list(params?: RecurringInvoiceListParams): Promise<RecurringInvoiceListResponse> {
const { data } = await client.get(API.RECURRING_INVOICES, { params })
return data
},
async get(id: number): Promise<ApiResponse<RecurringInvoice>> {
const { data } = await client.get(`${API.RECURRING_INVOICES}/${id}`)
return data
},
async create(payload: CreateRecurringInvoicePayload): Promise<ApiResponse<RecurringInvoice>> {
const { data } = await client.post(API.RECURRING_INVOICES, payload)
return data
},
async update(
id: number,
payload: Partial<CreateRecurringInvoicePayload>,
): Promise<ApiResponse<RecurringInvoice>> {
const { data } = await client.put(`${API.RECURRING_INVOICES}/${id}`, payload)
return data
},
async delete(payload: DeletePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.RECURRING_INVOICES_DELETE, payload)
return data
},
async getFrequencyDate(params: FrequencyDateParams): Promise<FrequencyDateResponse> {
const { data } = await client.get(API.RECURRING_INVOICE_FREQUENCY, { params })
return data
},
}

View File

@@ -0,0 +1,86 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { DateRangeParams } from '../../types/api'
export interface ReportParams extends DateRangeParams {
report_type?: string
}
export interface SalesReportResponse {
data: Array<{
date: string
total: number
count: number
}>
total: number
from_date: string
to_date: string
}
export interface ProfitLossReportResponse {
data: {
income: Array<{
label: string
amount: number
}>
expenses: Array<{
label: string
amount: number
}>
net_profit: number
}
from_date: string
to_date: string
}
export interface ExpenseReportResponse {
data: Array<{
category: string
total: number
count: number
}>
total: number
from_date: string
to_date: string
}
export interface TaxReportResponse {
data: Array<{
tax_name: string
tax_amount: number
invoice_count: number
}>
total: number
from_date: string
to_date: string
}
export const reportService = {
async getSalesReport(params: ReportParams): Promise<SalesReportResponse> {
const { data } = await client.get(API.DASHBOARD, {
params: { ...params, report_type: 'sales' },
})
return data
},
async getProfitLossReport(params: ReportParams): Promise<ProfitLossReportResponse> {
const { data } = await client.get(API.DASHBOARD, {
params: { ...params, report_type: 'profit_loss' },
})
return data
},
async getExpenseReport(params: ReportParams): Promise<ExpenseReportResponse> {
const { data } = await client.get(API.DASHBOARD, {
params: { ...params, report_type: 'expenses' },
})
return data
},
async getTaxReport(params: ReportParams): Promise<TaxReportResponse> {
const { data } = await client.get(API.DASHBOARD, {
params: { ...params, report_type: 'tax' },
})
return data
},
}

View File

@@ -0,0 +1,48 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Role, Ability } from '../../types/domain/role'
import type { ApiResponse, ListParams } from '../../types/api'
export interface CreateRolePayload {
name: string
abilities: Array<{
ability: string
model?: string | null
}>
}
export interface AbilitiesResponse {
abilities: Ability[]
}
export const roleService = {
async list(params?: ListParams): Promise<ApiResponse<Role[]>> {
const { data } = await client.get(API.ROLES, { params })
return data
},
async get(id: number): Promise<ApiResponse<Role>> {
const { data } = await client.get(`${API.ROLES}/${id}`)
return data
},
async create(payload: CreateRolePayload): Promise<{ role: Role }> {
const { data } = await client.post(API.ROLES, payload)
return data
},
async update(id: number, payload: Partial<CreateRolePayload>): Promise<ApiResponse<Role>> {
const { data } = await client.put(`${API.ROLES}/${id}`, payload)
return data
},
async delete(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.ROLES}/${id}`)
return data
},
async getAbilities(params?: ListParams): Promise<AbilitiesResponse> {
const { data } = await client.get(API.ABILITIES, { params })
return data
},
}

View File

@@ -0,0 +1,109 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { Country } from '../../types/domain/customer'
import type { Currency } from '../../types/domain/currency'
export interface DateFormat {
display_date: string
carbon_format_value: string
moment_format_value: string
}
export interface TimeFormat {
display_time: string
carbon_format_value: string
moment_format_value: string
}
export interface ConfigResponse {
[key: string]: unknown
}
export interface GlobalSettingsPayload {
settings: Record<string, string | number | boolean | null>
}
export interface NumberPlaceholdersParams {
key: string
}
export interface NumberPlaceholder {
description: string
value: string
}
export const settingService = {
// Global Settings (admin-level)
async getGlobalSettings(): Promise<Record<string, string>> {
const { data } = await client.get(API.SETTINGS)
return data
},
async updateGlobalSettings(payload: GlobalSettingsPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.SETTINGS, payload)
return data
},
// Config
async getConfig(params?: Record<string, string>): Promise<ConfigResponse> {
const { data } = await client.get(API.CONFIG, { params })
return data
},
// Reference Data
async getCountries(): Promise<{ data: Country[] }> {
const { data } = await client.get(API.COUNTRIES)
return data
},
async getCurrencies(): Promise<{ data: Currency[] }> {
const { data } = await client.get(API.CURRENCIES)
return data
},
async getTimezones(): Promise<{ time_zones: string[] }> {
const { data } = await client.get(API.TIMEZONES)
return data
},
async getDateFormats(): Promise<{ date_formats: DateFormat[] }> {
const { data } = await client.get(API.DATE_FORMATS)
return data
},
async getTimeFormats(): Promise<{ time_formats: TimeFormat[] }> {
const { data } = await client.get(API.TIME_FORMATS)
return data
},
// Serial Numbers
async getNextNumber(params: { key: string }): Promise<{ nextNumber: string }> {
const { data } = await client.get(API.NEXT_NUMBER, { params })
return data
},
async getNumberPlaceholders(params: NumberPlaceholdersParams): Promise<{ placeholders: NumberPlaceholder[] }> {
const { data } = await client.get(API.NUMBER_PLACEHOLDERS, { params })
return data
},
// Search
async search(params: { search: string }): Promise<{
users: { data: unknown[] }
customers: { data: unknown[] }
}> {
const { data } = await client.get(API.SEARCH, { params })
return data
},
async searchUsers(params: { search: string }): Promise<{ data: unknown[] }> {
const { data } = await client.get(API.SEARCH_USERS, { params })
return data
},
// App Version
async getAppVersion(): Promise<{ version: string }> {
const { data } = await client.get(API.APP_VERSION)
return data
},
}

View File

@@ -0,0 +1,41 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { TaxType } from '../../types/domain/tax'
import type { ApiResponse, ListParams } from '../../types/api'
export interface CreateTaxTypePayload {
name: string
percent: number
fixed_amount?: number
calculation_type?: string | null
compound_tax?: boolean
collective_tax?: number | null
description?: string | null
}
export const taxTypeService = {
async list(params?: ListParams): Promise<ApiResponse<TaxType[]>> {
const { data } = await client.get(API.TAX_TYPES, { params })
return data
},
async get(id: number): Promise<ApiResponse<TaxType>> {
const { data } = await client.get(`${API.TAX_TYPES}/${id}`)
return data
},
async create(payload: CreateTaxTypePayload): Promise<ApiResponse<TaxType>> {
const { data } = await client.post(API.TAX_TYPES, payload)
return data
},
async update(id: number, payload: Partial<CreateTaxTypePayload>): Promise<ApiResponse<TaxType>> {
const { data } = await client.put(`${API.TAX_TYPES}/${id}`, payload)
return data
},
async delete(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.TAX_TYPES}/${id}`)
return data
},
}

View File

@@ -0,0 +1,48 @@
import { client } from '../client'
import { API } from '../endpoints'
import type { User } from '../../types/domain/user'
import type { ApiResponse } from '../../types/api'
export interface UpdateProfilePayload {
name: string
email: string
password?: string | null
confirm_password?: string | null
}
export interface UserSettingsPayload {
settings: Record<string, string | number | boolean | null>
}
export interface UserSettingsResponse {
[key: string]: string | null
}
export const userService = {
async getProfile(): Promise<ApiResponse<User>> {
const { data } = await client.get(API.ME)
return data
},
async updateProfile(payload: UpdateProfilePayload): Promise<ApiResponse<User>> {
const { data } = await client.put(API.ME, payload)
return data
},
async getSettings(settings?: string[]): Promise<UserSettingsResponse> {
const { data } = await client.get(API.ME_SETTINGS, {
params: { settings },
})
return data
},
async updateSettings(payload: UserSettingsPayload): Promise<{ success: boolean }> {
const { data } = await client.put(API.ME_SETTINGS, payload)
return data
},
async uploadAvatar(payload: FormData): Promise<ApiResponse<User>> {
const { data } = await client.post(API.ME_UPLOAD_AVATAR, payload)
return data
},
}

View File

@@ -0,0 +1,54 @@
export { useApi } from './use-api'
export type { UseApiReturn } from './use-api'
export { useAuth } from './use-auth'
export type { User, UseAuthReturn } from './use-auth'
export { useNotification } from './use-notification'
export type { Notification, UseNotificationReturn } from './use-notification'
export { useDialog } from './use-dialog'
export type {
DialogState,
OpenConfirmOptions,
UseDialogReturn,
} from './use-dialog'
export { useModal } from './use-modal'
export type {
ModalState,
OpenModalOptions,
UseModalReturn,
} from './use-modal'
export { usePagination } from './use-pagination'
export type {
UsePaginationOptions,
UsePaginationReturn,
} from './use-pagination'
export { useFilters } from './use-filters'
export type { UseFiltersOptions, UseFiltersReturn } from './use-filters'
export { useCurrency } from './use-currency'
export type { Currency, UseCurrencyReturn } from './use-currency'
export { useDate } from './use-date'
export type { UseDateReturn } from './use-date'
export { useTheme } from './use-theme'
export type { UseThemeReturn } from './use-theme'
export { useSidebar } from './use-sidebar'
export type { UseSidebarReturn } from './use-sidebar'
export { useCompany } from './use-company'
export type {
Company,
CompanySettings,
CompanyCurrency,
UseCompanyReturn,
} from './use-company'
export { usePermissions } from './use-permissions'
export type { UserAbility, UsePermissionsReturn } from './use-permissions'

View File

@@ -0,0 +1,52 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import { handleApiError } from '../utils/error-handling'
import type { NormalizedApiError } from '../utils/error-handling'
export interface UseApiReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<NormalizedApiError | null>
execute: (...args: unknown[]) => Promise<T | null>
reset: () => void
}
/**
* Generic API call wrapper composable.
* Manages loading, error, and data state for an async API function.
*
* @param apiFn - An async function that performs the API call
* @returns Reactive refs for data, loading, and error plus an execute function
*/
export function useApi<T>(
apiFn: (...args: never[]) => Promise<T>
): UseApiReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref<boolean>(false)
const error = ref<NormalizedApiError | null>(null) as Ref<NormalizedApiError | null>
async function execute(...args: unknown[]): Promise<T | null> {
loading.value = true
error.value = null
try {
const result = await (apiFn as (...a: unknown[]) => Promise<T>)(...args)
data.value = result
return result
} catch (err: unknown) {
const normalized = handleApiError(err)
error.value = normalized
return null
} finally {
loading.value = false
}
}
function reset(): void {
data.value = null
loading.value = false
error.value = null
}
return { data, loading, error, execute, reset }
}

View File

@@ -0,0 +1,86 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import * as ls from '../utils/local-storage'
import { LS_KEYS } from '../config/constants'
export interface User {
id: number
name: string
email: string
avatar: string | number
is_owner: boolean
is_super_admin: boolean
[key: string]: unknown
}
export interface UseAuthReturn {
currentUser: Ref<User | null>
isAuthenticated: ComputedRef<boolean>
isOwner: ComputedRef<boolean>
isSuperAdmin: ComputedRef<boolean>
setUser: (user: User) => void
clearUser: () => void
login: (loginFn: () => Promise<User>) => Promise<User>
logout: (logoutFn: () => Promise<void>) => Promise<void>
}
const currentUser = ref<User | null>(null)
/**
* Composable for managing authentication state.
* Provides the current user, login/logout helpers, and role-based computed properties.
*/
export function useAuth(): UseAuthReturn {
const isAuthenticated = computed<boolean>(() => currentUser.value !== null)
const isOwner = computed<boolean>(
() => currentUser.value?.is_owner === true
)
const isSuperAdmin = computed<boolean>(
() => currentUser.value?.is_super_admin === true
)
function setUser(user: User): void {
currentUser.value = user
}
function clearUser(): void {
currentUser.value = null
}
/**
* Execute a login function and set the current user on success.
*
* @param loginFn - Async function that performs the login and returns a User
* @returns The authenticated user
*/
async function login(loginFn: () => Promise<User>): Promise<User> {
const user = await loginFn()
currentUser.value = user
return user
}
/**
* Execute a logout function and clear auth state.
*
* @param logoutFn - Async function that performs the logout
*/
async function logout(logoutFn: () => Promise<void>): Promise<void> {
await logoutFn()
currentUser.value = null
ls.remove(LS_KEYS.AUTH_TOKEN)
ls.remove(LS_KEYS.SELECTED_COMPANY)
}
return {
currentUser,
isAuthenticated,
isOwner,
isSuperAdmin,
setUser,
clearUser,
login,
logout,
}
}

View File

@@ -0,0 +1,134 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import * as ls from '../utils/local-storage'
import { LS_KEYS } from '../config/constants'
export interface Company {
id: number
unique_hash: string
name: string
slug: string
[key: string]: unknown
}
export interface CompanySettings {
[key: string]: unknown
}
export interface CompanyCurrency {
id: number
code: string
name: string
symbol: string
precision: number
thousand_separator: string
decimal_separator: string
swap_currency_symbol?: boolean
exchange_rate?: number
[key: string]: unknown
}
export interface UseCompanyReturn {
selectedCompany: Ref<Company | null>
companies: Ref<Company[]>
selectedCompanySettings: Ref<CompanySettings>
selectedCompanyCurrency: Ref<CompanyCurrency | null>
isAdminMode: Ref<boolean>
hasCompany: ComputedRef<boolean>
setCompany: (company: Company | null) => void
setCompanies: (data: Company[]) => void
setCompanySettings: (settings: CompanySettings) => void
setCompanyCurrency: (currency: CompanyCurrency | null) => void
enterAdminMode: () => void
exitAdminMode: () => void
}
const selectedCompany = ref<Company | null>(null)
const companies = ref<Company[]>([])
const selectedCompanySettings = ref<CompanySettings>({})
const selectedCompanyCurrency = ref<CompanyCurrency | null>(null)
const isAdminMode = ref<boolean>(
ls.get<string>(LS_KEYS.IS_ADMIN_MODE) === 'true'
)
/**
* Composable for managing company selection and admin mode state.
* Extracted from the Pinia company store, this provides reactive company state
* with localStorage persistence for the selected company and admin mode.
*/
export function useCompany(): UseCompanyReturn {
const hasCompany = computed<boolean>(
() => selectedCompany.value !== null
)
/**
* Set the selected company and persist to localStorage.
* Automatically disables admin mode when a company is selected.
*
* @param company - The company to select, or null to deselect
*/
function setCompany(company: Company | null): void {
if (company) {
ls.set(LS_KEYS.SELECTED_COMPANY, String(company.id))
ls.remove(LS_KEYS.IS_ADMIN_MODE)
isAdminMode.value = false
} else {
ls.remove(LS_KEYS.SELECTED_COMPANY)
}
selectedCompany.value = company
}
/**
* Set the list of available companies.
*/
function setCompanies(data: Company[]): void {
companies.value = data
}
/**
* Update the selected company's settings.
*/
function setCompanySettings(settings: CompanySettings): void {
selectedCompanySettings.value = settings
}
/**
* Update the selected company's currency.
*/
function setCompanyCurrency(currency: CompanyCurrency | null): void {
selectedCompanyCurrency.value = currency
}
/**
* Enter admin mode. Clears the selected company and persists admin mode.
*/
function enterAdminMode(): void {
isAdminMode.value = true
ls.set(LS_KEYS.IS_ADMIN_MODE, 'true')
ls.remove(LS_KEYS.SELECTED_COMPANY)
selectedCompany.value = null
}
/**
* Exit admin mode.
*/
function exitAdminMode(): void {
isAdminMode.value = false
ls.remove(LS_KEYS.IS_ADMIN_MODE)
}
return {
selectedCompany,
companies,
selectedCompanySettings,
selectedCompanyCurrency,
isAdminMode,
hasCompany,
setCompany,
setCompanies,
setCompanySettings,
setCompanyCurrency,
enterAdminMode,
exitAdminMode,
}
}

View File

@@ -0,0 +1,105 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import { formatMoney as formatMoneyUtil } from '../utils/format-money'
import type { CurrencyConfig } from '../utils/format-money'
export interface Currency {
id: number
code: string
name: string
precision: number
thousand_separator: string
decimal_separator: string
symbol: string
swap_currency_symbol?: boolean
exchange_rate?: number
[key: string]: unknown
}
export interface UseCurrencyReturn {
currencies: Ref<Currency[]>
setCurrencies: (data: Currency[]) => void
formatMoney: (amountInCents: number, currency?: CurrencyConfig) => string
convertCurrency: (
amountInCents: number,
fromRate: number,
toRate: number
) => number
findCurrencyByCode: (code: string) => Currency | undefined
}
const currencies = ref<Currency[]>([])
/**
* Default currency configuration matching the v1 behavior.
*/
const DEFAULT_CURRENCY_CONFIG: CurrencyConfig = {
precision: 2,
thousand_separator: ',',
decimal_separator: '.',
symbol: '$',
swap_currency_symbol: false,
}
/**
* Composable for currency formatting and conversion.
* Maintains a shared list of available currencies.
*/
export function useCurrency(): UseCurrencyReturn {
function setCurrencies(data: Currency[]): void {
currencies.value = data
}
/**
* Format an amount in cents using the provided or default currency config.
*
* @param amountInCents - Amount in cents
* @param currency - Optional currency config override
* @returns Formatted currency string
*/
function formatMoney(
amountInCents: number,
currency?: CurrencyConfig
): string {
return formatMoneyUtil(amountInCents, currency ?? DEFAULT_CURRENCY_CONFIG)
}
/**
* Convert an amount from one currency to another using exchange rates.
*
* @param amountInCents - Amount in cents in the source currency
* @param fromRate - Exchange rate of the source currency
* @param toRate - Exchange rate of the target currency
* @returns Converted amount in cents
*/
function convertCurrency(
amountInCents: number,
fromRate: number,
toRate: number
): number {
if (fromRate === 0) {
return 0
}
return Math.round((amountInCents / fromRate) * toRate)
}
/**
* Find a currency by its ISO code.
*
* @param code - The ISO 4217 currency code (e.g. "USD")
* @returns The matching Currency, or undefined
*/
function findCurrencyByCode(code: string): Currency | undefined {
return currencies.value.find(
(c) => c.code.toUpperCase() === code.toUpperCase()
)
}
return {
currencies,
setCurrencies,
formatMoney,
convertCurrency,
findCurrencyByCode,
}
}

View File

@@ -0,0 +1,92 @@
import {
formatDate as formatDateUtil,
relativeTime as relativeTimeUtil,
parseDate as parseDateUtil,
DEFAULT_DATE_FORMAT,
DEFAULT_DATETIME_FORMAT,
} from '../utils/format-date'
import type { Locale } from 'date-fns'
export interface UseDateReturn {
formatDate: (
date: Date | string | number,
formatStr?: string,
options?: { locale?: Locale }
) => string
formatDateTime: (
date: Date | string | number,
options?: { locale?: Locale }
) => string
relativeTime: (
date: Date | string | number,
options?: { addSuffix?: boolean; locale?: Locale }
) => string
parseDate: (date: Date | string | number) => Date | null
}
/**
* Composable for date formatting and parsing using date-fns.
* Provides convenient wrappers around utility functions for use within Vue components.
*/
export function useDate(): UseDateReturn {
/**
* Format a date using a format pattern.
*
* @param date - A Date object, ISO string, or timestamp
* @param formatStr - date-fns format pattern (default: 'yyyy-MM-dd')
* @param options - Optional locale
* @returns Formatted date string, or empty string if invalid
*/
function formatDate(
date: Date | string | number,
formatStr: string = DEFAULT_DATE_FORMAT,
options?: { locale?: Locale }
): string {
return formatDateUtil(date, formatStr, options)
}
/**
* Format a date with date and time.
*
* @param date - A Date object, ISO string, or timestamp
* @param options - Optional locale
* @returns Formatted datetime string
*/
function formatDateTime(
date: Date | string | number,
options?: { locale?: Locale }
): string {
return formatDateUtil(date, DEFAULT_DATETIME_FORMAT, options)
}
/**
* Get a human-readable relative time string (e.g. "3 days ago").
*
* @param date - A Date object, ISO string, or timestamp
* @param options - Optional addSuffix and locale settings
* @returns Relative time string
*/
function relativeTime(
date: Date | string | number,
options?: { addSuffix?: boolean; locale?: Locale }
): string {
return relativeTimeUtil(date, options)
}
/**
* Parse a date value into a Date object.
*
* @param date - A Date object, ISO string, or timestamp
* @returns Parsed Date or null
*/
function parseDate(date: Date | string | number): Date | null {
return parseDateUtil(date)
}
return {
formatDate,
formatDateTime,
relativeTime,
parseDate,
}
}

View File

@@ -0,0 +1,113 @@
import { ref, readonly } from 'vue'
import type { DeepReadonly, Ref } from 'vue'
import { DIALOG_VARIANT } from '../config/constants'
import type { DialogVariant } from '../config/constants'
export interface DialogState {
active: boolean
title: string
message: string
variant: DialogVariant
yesLabel: string
noLabel: string
hideNoButton: boolean
data: unknown
}
export interface OpenConfirmOptions {
title: string
message: string
variant?: DialogVariant
yesLabel?: string
noLabel?: string
hideNoButton?: boolean
data?: unknown
}
export interface UseDialogReturn {
dialogState: DeepReadonly<Ref<DialogState>>
openConfirm: (options: OpenConfirmOptions) => Promise<boolean>
closeDialog: () => void
confirmDialog: () => void
cancelDialog: () => void
}
const DEFAULT_STATE: DialogState = {
active: false,
title: '',
message: '',
variant: DIALOG_VARIANT.DANGER,
yesLabel: 'Yes',
noLabel: 'No',
hideNoButton: false,
data: null,
}
const dialogState = ref<DialogState>({ ...DEFAULT_STATE })
let resolvePromise: ((value: boolean) => void) | null = null
/**
* Composable for managing confirmation dialogs.
* Returns a promise that resolves to true (confirmed) or false (cancelled).
*/
export function useDialog(): UseDialogReturn {
/**
* Open a confirmation dialog and await the user's response.
*
* @param options - Dialog configuration
* @returns Promise that resolves to true if confirmed, false if cancelled
*/
function openConfirm(options: OpenConfirmOptions): Promise<boolean> {
dialogState.value = {
active: true,
title: options.title,
message: options.message,
variant: options.variant ?? DIALOG_VARIANT.DANGER,
yesLabel: options.yesLabel ?? 'Yes',
noLabel: options.noLabel ?? 'No',
hideNoButton: options.hideNoButton ?? false,
data: options.data ?? null,
}
return new Promise<boolean>((resolve) => {
resolvePromise = resolve
})
}
function confirmDialog(): void {
if (resolvePromise) {
resolvePromise(true)
resolvePromise = null
}
resetDialog()
}
function cancelDialog(): void {
if (resolvePromise) {
resolvePromise(false)
resolvePromise = null
}
resetDialog()
}
function closeDialog(): void {
cancelDialog()
}
function resetDialog(): void {
dialogState.value.active = false
setTimeout(() => {
dialogState.value = { ...DEFAULT_STATE }
}, 300)
}
return {
dialogState: readonly(dialogState),
openConfirm,
closeDialog,
confirmDialog,
cancelDialog,
}
}

View File

@@ -0,0 +1,105 @@
import { reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
export interface UseFiltersOptions<T extends Record<string, unknown>> {
initialFilters: T
debounceMs?: number
onChange?: (filters: T) => void
}
export interface UseFiltersReturn<T extends Record<string, unknown>> {
filters: T
activeFilterCount: Ref<number>
setFilter: <K extends keyof T>(key: K, value: T[K]) => void
clearFilters: () => void
clearFilter: <K extends keyof T>(key: K) => void
getFiltersSnapshot: () => T
}
/**
* Composable for managing list filter state with optional debounced apply.
*
* @param options - Configuration including initial filter values, debounce delay, and change callback
*/
export function useFilters<T extends Record<string, unknown>>(
options: UseFiltersOptions<T>
): UseFiltersReturn<T> {
const { initialFilters, debounceMs = 300, onChange } = options
const filters = reactive<T>({ ...initialFilters }) as T
const activeFilterCount = ref<number>(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function updateActiveFilterCount(): void {
let count = 0
const initialKeys = Object.keys(initialFilters) as Array<keyof T>
for (const key of initialKeys) {
const current = filters[key]
const initial = initialFilters[key]
if (current !== initial && current !== '' && current !== null && current !== undefined) {
count++
}
}
activeFilterCount.value = count
}
function debouncedApply(): void {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
updateActiveFilterCount()
onChange?.({ ...filters })
}, debounceMs)
}
function setFilter<K extends keyof T>(key: K, value: T[K]): void {
filters[key] = value
debouncedApply()
}
function clearFilter<K extends keyof T>(key: K): void {
filters[key] = initialFilters[key]
debouncedApply()
}
function clearFilters(): void {
const keys = Object.keys(initialFilters) as Array<keyof T>
for (const key of keys) {
filters[key] = initialFilters[key]
}
updateActiveFilterCount()
onChange?.({ ...filters })
}
function getFiltersSnapshot(): T {
return { ...filters }
}
// Watch for external reactive changes and apply debounce
watch(
() => ({ ...filters }),
() => {
debouncedApply()
},
{ deep: true }
)
// Initialize the active count
updateActiveFilterCount()
return {
filters,
activeFilterCount,
setFilter,
clearFilters,
clearFilter,
getFiltersSnapshot,
}
}

View File

@@ -0,0 +1,101 @@
import { ref, computed, readonly } from 'vue'
import type { DeepReadonly, Ref, ComputedRef } from 'vue'
import type { ModalSize } from '../config/constants'
export interface ModalState {
active: boolean
componentName: string
title: string
content: string
id: string
size: ModalSize
data: unknown
refreshData: (() => void) | null
variant: string
}
export interface OpenModalOptions {
componentName: string
title?: string
content?: string
id?: string
size?: ModalSize
data?: unknown
refreshData?: () => void
variant?: string
}
export interface UseModalReturn {
modalState: DeepReadonly<Ref<ModalState>>
isEdit: ComputedRef<boolean>
openModal: (options: OpenModalOptions) => void
closeModal: () => void
resetModalData: () => void
}
const DEFAULT_STATE: ModalState = {
active: false,
componentName: '',
title: '',
content: '',
id: '',
size: 'md',
data: null,
refreshData: null,
variant: '',
}
const modalState = ref<ModalState>({ ...DEFAULT_STATE })
/**
* Composable for managing a global modal.
* Supports opening modals by component name with props, and tracks edit vs create mode.
*/
export function useModal(): UseModalReturn {
const isEdit = computed<boolean>(() => modalState.value.id !== '')
/**
* Open a modal with the specified options.
*
* @param options - Modal configuration including component name and optional props
*/
function openModal(options: OpenModalOptions): void {
modalState.value = {
active: true,
componentName: options.componentName,
title: options.title ?? '',
content: options.content ?? '',
id: options.id ?? '',
size: options.size ?? 'md',
data: options.data ?? null,
refreshData: options.refreshData ?? null,
variant: options.variant ?? '',
}
}
/**
* Close the modal with a brief delay for animation.
*/
function closeModal(): void {
modalState.value.active = false
setTimeout(() => {
resetModalData()
}, 300)
}
/**
* Reset modal data back to defaults.
*/
function resetModalData(): void {
modalState.value = { ...DEFAULT_STATE }
}
return {
modalState: readonly(modalState),
isEdit,
openModal,
closeModal,
resetModalData,
}
}

View File

@@ -0,0 +1,83 @@
import { ref, readonly } from 'vue'
import type { DeepReadonly, Ref } from 'vue'
import { NOTIFICATION_TYPE } from '../config/constants'
import type { NotificationType } from '../config/constants'
export interface Notification {
id: string
type: NotificationType
message: string
}
export interface UseNotificationReturn {
notifications: DeepReadonly<Ref<Notification[]>>
showSuccess: (message: string) => void
showError: (message: string) => void
showInfo: (message: string) => void
showWarning: (message: string) => void
showNotification: (type: NotificationType, message: string) => void
hideNotification: (id: string) => void
clearAll: () => void
}
const notifications = ref<Notification[]>([])
/**
* Generate a unique ID for a notification.
*/
function generateId(): string {
return (
Math.random().toString(36) + Date.now().toString(36)
).substring(2)
}
/**
* Composable for managing application-wide notifications.
* Provides typed helpers for success, error, info, and warning notifications.
*/
export function useNotification(): UseNotificationReturn {
function showNotification(type: NotificationType, message: string): void {
notifications.value.push({
id: generateId(),
type,
message,
})
}
function showSuccess(message: string): void {
showNotification(NOTIFICATION_TYPE.SUCCESS, message)
}
function showError(message: string): void {
showNotification(NOTIFICATION_TYPE.ERROR, message)
}
function showInfo(message: string): void {
showNotification(NOTIFICATION_TYPE.INFO, message)
}
function showWarning(message: string): void {
showNotification(NOTIFICATION_TYPE.WARNING, message)
}
function hideNotification(id: string): void {
notifications.value = notifications.value.filter(
(notification) => notification.id !== id
)
}
function clearAll(): void {
notifications.value = []
}
return {
notifications: readonly(notifications),
showSuccess,
showError,
showInfo,
showWarning,
showNotification,
hideNotification,
clearAll,
}
}

View File

@@ -0,0 +1,91 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { PAGINATION_DEFAULTS } from '../config/constants'
export interface UsePaginationOptions {
initialPage?: number
initialLimit?: number
}
export interface UsePaginationReturn {
page: Ref<number>
limit: Ref<number>
totalCount: Ref<number>
totalPages: ComputedRef<number>
hasNextPage: ComputedRef<boolean>
hasPrevPage: ComputedRef<boolean>
nextPage: () => void
prevPage: () => void
goToPage: (target: number) => void
setTotalCount: (count: number) => void
reset: () => void
}
/**
* Composable for managing pagination state.
* Tracks page, limit, total count, and provides navigation helpers.
*
* @param options - Optional initial page and limit values
*/
export function usePagination(
options?: UsePaginationOptions
): UsePaginationReturn {
const initialPage = options?.initialPage ?? PAGINATION_DEFAULTS.PAGE
const initialLimit = options?.initialLimit ?? PAGINATION_DEFAULTS.LIMIT
const page = ref<number>(initialPage)
const limit = ref<number>(initialLimit)
const totalCount = ref<number>(0)
const totalPages = computed<number>(() => {
if (totalCount.value === 0 || limit.value === 0) {
return 0
}
return Math.ceil(totalCount.value / limit.value)
})
const hasNextPage = computed<boolean>(() => page.value < totalPages.value)
const hasPrevPage = computed<boolean>(() => page.value > 1)
function nextPage(): void {
if (hasNextPage.value) {
page.value += 1
}
}
function prevPage(): void {
if (hasPrevPage.value) {
page.value -= 1
}
}
function goToPage(target: number): void {
if (target >= 1 && target <= totalPages.value) {
page.value = target
}
}
function setTotalCount(count: number): void {
totalCount.value = count
}
function reset(): void {
page.value = initialPage
totalCount.value = 0
}
return {
page,
limit,
totalCount,
totalPages,
hasNextPage,
hasPrevPage,
nextPage,
prevPage,
goToPage,
setTotalCount,
reset,
}
}

View File

@@ -0,0 +1,160 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { ABILITIES } from '../config/abilities'
import type { Ability } from '../config/abilities'
export interface UserAbility {
name: string
[key: string]: unknown
}
export interface UsePermissionsReturn {
currentAbilities: Ref<UserAbility[]>
isOwner: Ref<boolean>
isSuperAdmin: Ref<boolean>
setAbilities: (abilities: UserAbility[]) => void
setOwner: (owner: boolean) => void
setSuperAdmin: (superAdmin: boolean) => void
hasAbility: (ability: Ability | Ability[]) => boolean
hasAllAbilities: (abilities: Ability[]) => boolean
canViewCustomer: ComputedRef<boolean>
canCreateCustomer: ComputedRef<boolean>
canEditCustomer: ComputedRef<boolean>
canDeleteCustomer: ComputedRef<boolean>
canViewInvoice: ComputedRef<boolean>
canCreateInvoice: ComputedRef<boolean>
canEditInvoice: ComputedRef<boolean>
canDeleteInvoice: ComputedRef<boolean>
canViewEstimate: ComputedRef<boolean>
canCreateEstimate: ComputedRef<boolean>
canEditEstimate: ComputedRef<boolean>
canDeleteEstimate: ComputedRef<boolean>
canViewPayment: ComputedRef<boolean>
canCreatePayment: ComputedRef<boolean>
canEditPayment: ComputedRef<boolean>
canDeletePayment: ComputedRef<boolean>
canViewExpense: ComputedRef<boolean>
canCreateExpense: ComputedRef<boolean>
canEditExpense: ComputedRef<boolean>
canDeleteExpense: ComputedRef<boolean>
canViewDashboard: ComputedRef<boolean>
canViewFinancialReport: ComputedRef<boolean>
}
const currentAbilities = ref<UserAbility[]>([])
const isOwner = ref<boolean>(false)
const isSuperAdmin = ref<boolean>(false)
/**
* Composable for managing user permissions and abilities.
* Extracted from the user store's hasAbilities/hasAllAbilities logic,
* with typed convenience computed properties for common CRUD checks.
*/
export function usePermissions(): UsePermissionsReturn {
function setAbilities(abilities: UserAbility[]): void {
currentAbilities.value = abilities
}
function setOwner(owner: boolean): void {
isOwner.value = owner
}
function setSuperAdmin(superAdmin: boolean): void {
isSuperAdmin.value = superAdmin
}
/**
* Check if the current user has a given ability or any of the given abilities.
* A wildcard ability ('*') grants access to everything.
*
* @param ability - A single ability string or array of abilities
* @returns True if the user has the ability
*/
function hasAbility(ability: Ability | Ability[]): boolean {
return !!currentAbilities.value.find((ab) => {
if (ab.name === '*') return true
if (typeof ability === 'string') {
return ab.name === ability
}
return !!ability.find((p) => ab.name === p)
})
}
/**
* Check if the current user has ALL of the given abilities.
*
* @param abilities - Array of abilities that must all be present
* @returns True if the user has every listed ability
*/
function hasAllAbilities(abilities: Ability[]): boolean {
return abilities.every((ability) =>
currentAbilities.value.some(
(ab) => ab.name === '*' || ab.name === ability
)
)
}
// Convenience computed properties for common permission checks
const canViewCustomer = computed<boolean>(() => hasAbility(ABILITIES.VIEW_CUSTOMER))
const canCreateCustomer = computed<boolean>(() => hasAbility(ABILITIES.CREATE_CUSTOMER))
const canEditCustomer = computed<boolean>(() => hasAbility(ABILITIES.EDIT_CUSTOMER))
const canDeleteCustomer = computed<boolean>(() => hasAbility(ABILITIES.DELETE_CUSTOMER))
const canViewInvoice = computed<boolean>(() => hasAbility(ABILITIES.VIEW_INVOICE))
const canCreateInvoice = computed<boolean>(() => hasAbility(ABILITIES.CREATE_INVOICE))
const canEditInvoice = computed<boolean>(() => hasAbility(ABILITIES.EDIT_INVOICE))
const canDeleteInvoice = computed<boolean>(() => hasAbility(ABILITIES.DELETE_INVOICE))
const canViewEstimate = computed<boolean>(() => hasAbility(ABILITIES.VIEW_ESTIMATE))
const canCreateEstimate = computed<boolean>(() => hasAbility(ABILITIES.CREATE_ESTIMATE))
const canEditEstimate = computed<boolean>(() => hasAbility(ABILITIES.EDIT_ESTIMATE))
const canDeleteEstimate = computed<boolean>(() => hasAbility(ABILITIES.DELETE_ESTIMATE))
const canViewPayment = computed<boolean>(() => hasAbility(ABILITIES.VIEW_PAYMENT))
const canCreatePayment = computed<boolean>(() => hasAbility(ABILITIES.CREATE_PAYMENT))
const canEditPayment = computed<boolean>(() => hasAbility(ABILITIES.EDIT_PAYMENT))
const canDeletePayment = computed<boolean>(() => hasAbility(ABILITIES.DELETE_PAYMENT))
const canViewExpense = computed<boolean>(() => hasAbility(ABILITIES.VIEW_EXPENSE))
const canCreateExpense = computed<boolean>(() => hasAbility(ABILITIES.CREATE_EXPENSE))
const canEditExpense = computed<boolean>(() => hasAbility(ABILITIES.EDIT_EXPENSE))
const canDeleteExpense = computed<boolean>(() => hasAbility(ABILITIES.DELETE_EXPENSE))
const canViewDashboard = computed<boolean>(() => hasAbility(ABILITIES.DASHBOARD))
const canViewFinancialReport = computed<boolean>(() => hasAbility(ABILITIES.VIEW_FINANCIAL_REPORT))
return {
currentAbilities,
isOwner,
isSuperAdmin,
setAbilities,
setOwner,
setSuperAdmin,
hasAbility,
hasAllAbilities,
canViewCustomer,
canCreateCustomer,
canEditCustomer,
canDeleteCustomer,
canViewInvoice,
canCreateInvoice,
canEditInvoice,
canDeleteInvoice,
canViewEstimate,
canCreateEstimate,
canEditEstimate,
canDeleteEstimate,
canViewPayment,
canCreatePayment,
canEditPayment,
canDeletePayment,
canViewExpense,
canCreateExpense,
canEditExpense,
canDeleteExpense,
canViewDashboard,
canViewFinancialReport,
}
}

View File

@@ -0,0 +1,49 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import * as ls from '../utils/local-storage'
import { LS_KEYS } from '../config/constants'
export interface UseSidebarReturn {
isCollapsed: Ref<boolean>
isSidebarOpen: Ref<boolean>
toggleCollapse: () => void
setSidebarVisibility: (visible: boolean) => void
}
const isCollapsed = ref<boolean>(
ls.get<string>(LS_KEYS.SIDEBAR_COLLAPSED) === 'true'
)
const isSidebarOpen = ref<boolean>(false)
/**
* Composable for managing sidebar state.
* Handles both the mobile sidebar open/close and the desktop collapsed toggle.
* Persists the collapsed state to localStorage.
*/
export function useSidebar(): UseSidebarReturn {
/**
* Toggle the sidebar collapsed/expanded state (desktop).
* Persists the new value to localStorage.
*/
function toggleCollapse(): void {
isCollapsed.value = !isCollapsed.value
ls.set(LS_KEYS.SIDEBAR_COLLAPSED, isCollapsed.value ? 'true' : 'false')
}
/**
* Set the mobile sidebar visibility.
*
* @param visible - Whether the sidebar should be visible
*/
function setSidebarVisibility(visible: boolean): void {
isSidebarOpen.value = visible
}
return {
isCollapsed,
isSidebarOpen,
toggleCollapse,
setSidebarVisibility,
}
}

View File

@@ -0,0 +1,92 @@
import { ref, onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'
import { THEME, LS_KEYS } from '../config/constants'
import type { Theme } from '../config/constants'
import * as ls from '../utils/local-storage'
export interface UseThemeReturn {
currentTheme: Ref<Theme>
setTheme: (theme: Theme) => void
applyTheme: (theme?: Theme) => void
}
const currentTheme = ref<Theme>(
(ls.get<string>(LS_KEYS.THEME) as Theme) ?? THEME.SYSTEM
)
let mediaQueryCleanup: (() => void) | null = null
/**
* Apply the correct data-theme attribute to the document element.
*
* @param theme - The theme to apply (light, dark, or system)
*/
function applyThemeToDocument(theme: Theme): void {
const prefersDark =
theme === THEME.DARK ||
(theme === THEME.SYSTEM &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
if (prefersDark) {
document.documentElement.setAttribute('data-theme', 'dark')
} else {
document.documentElement.removeAttribute('data-theme')
}
}
/**
* Composable for managing the application theme (light/dark/system).
* Extracted from TheSiteHeader. Listens for system preference changes
* when set to "system" mode.
*/
export function useTheme(): UseThemeReturn {
/**
* Set and persist the current theme.
*
* @param theme - The theme to set
*/
function setTheme(theme: Theme): void {
currentTheme.value = theme
ls.set(LS_KEYS.THEME, theme)
applyThemeToDocument(theme)
}
/**
* Apply the given or current theme to the document.
*
* @param theme - Optional theme override; uses currentTheme if not provided
*/
function applyTheme(theme?: Theme): void {
applyThemeToDocument(theme ?? currentTheme.value)
}
function handleSystemThemeChange(): void {
if (currentTheme.value === THEME.SYSTEM) {
applyThemeToDocument(THEME.SYSTEM)
}
}
onMounted(() => {
applyThemeToDocument(currentTheme.value)
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', handleSystemThemeChange)
mediaQueryCleanup = () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
})
onUnmounted(() => {
if (mediaQueryCleanup) {
mediaQueryCleanup()
mediaQueryCleanup = null
}
})
return {
currentTheme,
setTheme,
applyTheme,
}
}

View File

@@ -0,0 +1,82 @@
export const ABILITIES = {
// Dashboard
DASHBOARD: 'dashboard',
// Customers
CREATE_CUSTOMER: 'create-customer',
DELETE_CUSTOMER: 'delete-customer',
EDIT_CUSTOMER: 'edit-customer',
VIEW_CUSTOMER: 'view-customer',
// Items
CREATE_ITEM: 'create-item',
DELETE_ITEM: 'delete-item',
EDIT_ITEM: 'edit-item',
VIEW_ITEM: 'view-item',
// Tax Types
CREATE_TAX_TYPE: 'create-tax-type',
DELETE_TAX_TYPE: 'delete-tax-type',
EDIT_TAX_TYPE: 'edit-tax-type',
VIEW_TAX_TYPE: 'view-tax-type',
// Estimates
CREATE_ESTIMATE: 'create-estimate',
DELETE_ESTIMATE: 'delete-estimate',
EDIT_ESTIMATE: 'edit-estimate',
VIEW_ESTIMATE: 'view-estimate',
SEND_ESTIMATE: 'send-estimate',
// Invoices
CREATE_INVOICE: 'create-invoice',
DELETE_INVOICE: 'delete-invoice',
EDIT_INVOICE: 'edit-invoice',
VIEW_INVOICE: 'view-invoice',
SEND_INVOICE: 'send-invoice',
// Recurring Invoices
CREATE_RECURRING_INVOICE: 'create-recurring-invoice',
DELETE_RECURRING_INVOICE: 'delete-recurring-invoice',
EDIT_RECURRING_INVOICE: 'edit-recurring-invoice',
VIEW_RECURRING_INVOICE: 'view-recurring-invoice',
// Payments
CREATE_PAYMENT: 'create-payment',
DELETE_PAYMENT: 'delete-payment',
EDIT_PAYMENT: 'edit-payment',
VIEW_PAYMENT: 'view-payment',
SEND_PAYMENT: 'send-payment',
// Expenses
CREATE_EXPENSE: 'create-expense',
DELETE_EXPENSE: 'delete-expense',
EDIT_EXPENSE: 'edit-expense',
VIEW_EXPENSE: 'view-expense',
// Custom Fields
CREATE_CUSTOM_FIELDS: 'create-custom-field',
DELETE_CUSTOM_FIELDS: 'delete-custom-field',
EDIT_CUSTOM_FIELDS: 'edit-custom-field',
VIEW_CUSTOM_FIELDS: 'view-custom-field',
// Roles
CREATE_ROLE: 'create-role',
DELETE_ROLE: 'delete-role',
EDIT_ROLE: 'edit-role',
VIEW_ROLE: 'view-role',
// Exchange Rates
VIEW_EXCHANGE_RATE: 'view-exchange-rate-provider',
CREATE_EXCHANGE_RATE: 'create-exchange-rate-provider',
EDIT_EXCHANGE_RATE: 'edit-exchange-rate-provider',
DELETE_EXCHANGE_RATE: 'delete-exchange-rate-provider',
// Reports
VIEW_FINANCIAL_REPORT: 'view-financial-reports',
// Notes
MANAGE_NOTE: 'manage-all-notes',
VIEW_NOTE: 'view-all-notes',
} as const
export type Ability = typeof ABILITIES[keyof typeof ABILITIES]

View File

@@ -0,0 +1,101 @@
/**
* App-wide constants for the InvoiceShelf application.
*/
/** Document status values */
export const DOCUMENT_STATUS = {
DRAFT: 'DRAFT',
SENT: 'SENT',
VIEWED: 'VIEWED',
EXPIRED: 'EXPIRED',
ACCEPTED: 'ACCEPTED',
REJECTED: 'REJECTED',
PAID: 'PAID',
UNPAID: 'UNPAID',
PARTIALLY_PAID: 'PARTIALLY PAID',
COMPLETED: 'COMPLETED',
DUE: 'DUE',
} as const
export type DocumentStatus = typeof DOCUMENT_STATUS[keyof typeof DOCUMENT_STATUS]
/** Badge color configuration for document statuses */
export interface BadgeColor {
bgColor: string
color: string
}
export const STATUS_BADGE_COLORS: Record<string, BadgeColor> = {
DRAFT: { bgColor: '#F8EDCB', color: '#744210' },
PAID: { bgColor: '#D5EED0', color: '#276749' },
UNPAID: { bgColor: '#F8EDC', color: '#744210' },
SENT: { bgColor: 'rgba(246, 208, 154, 0.4)', color: '#975a16' },
REJECTED: { bgColor: '#E1E0EA', color: '#1A1841' },
ACCEPTED: { bgColor: '#D5EED0', color: '#276749' },
VIEWED: { bgColor: '#C9E3EC', color: '#2c5282' },
EXPIRED: { bgColor: '#FED7D7', color: '#c53030' },
'PARTIALLY PAID': { bgColor: '#C9E3EC', color: '#2c5282' },
COMPLETED: { bgColor: '#D5EED0', color: '#276749' },
DUE: { bgColor: '#F8EDCB', color: '#744210' },
YES: { bgColor: '#D5EED0', color: '#276749' },
NO: { bgColor: '#FED7D7', color: '#c53030' },
}
/** Theme options */
export const THEME = {
LIGHT: 'light',
DARK: 'dark',
SYSTEM: 'system',
} as const
export type Theme = typeof THEME[keyof typeof THEME]
/** Local storage keys used throughout the app */
export const LS_KEYS = {
AUTH_TOKEN: 'auth.token',
SELECTED_COMPANY: 'selectedCompany',
IS_ADMIN_MODE: 'isAdminMode',
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
THEME: 'theme',
} as const
/** Notification types */
export const NOTIFICATION_TYPE = {
SUCCESS: 'success',
ERROR: 'error',
INFO: 'info',
WARNING: 'warning',
} as const
export type NotificationType = typeof NOTIFICATION_TYPE[keyof typeof NOTIFICATION_TYPE]
/** Pagination defaults */
export const PAGINATION_DEFAULTS = {
PAGE: 1,
LIMIT: 15,
} as const
/** Dialog variant options */
export const DIALOG_VARIANT = {
PRIMARY: 'primary',
DANGER: 'danger',
} as const
export type DialogVariant = typeof DIALOG_VARIANT[keyof typeof DIALOG_VARIANT]
/** Modal size options */
export const MODAL_SIZE = {
SM: 'sm',
MD: 'md',
LG: 'lg',
XL: 'xl',
} as const
export type ModalSize = typeof MODAL_SIZE[keyof typeof MODAL_SIZE]
/** Valid image MIME types for uploads */
export const VALID_IMAGE_TYPES = [
'image/gif',
'image/jpeg',
'image/png',
] as const

View File

@@ -0,0 +1,23 @@
export { ABILITIES } from './abilities'
export type { Ability } from './abilities'
export {
DOCUMENT_STATUS,
STATUS_BADGE_COLORS,
THEME,
LS_KEYS,
NOTIFICATION_TYPE,
PAGINATION_DEFAULTS,
DIALOG_VARIANT,
MODAL_SIZE,
VALID_IMAGE_TYPES,
} from './constants'
export type {
DocumentStatus,
BadgeColor,
Theme,
NotificationType,
DialogVariant,
ModalSize,
} from './constants'

View File

@@ -0,0 +1,41 @@
export interface ApiResponse<T> {
data: T
}
export interface PaginatedResponse<T> {
data: T[]
meta: PaginationMeta
}
export interface PaginationMeta {
current_page: number
last_page: number
per_page: number
total: number
}
export interface ApiError {
message: string
errors?: Record<string, string[]>
}
export interface ListParams {
page?: number
limit?: number | 'all'
orderByField?: string
orderBy?: 'asc' | 'desc'
search?: string
}
export interface DateRangeParams {
from_date?: string
to_date?: string
}
export interface NextNumberResponse {
nextNumber: string
}
export interface DeletePayload {
ids: number[]
}

View File

@@ -0,0 +1,47 @@
import type { Address } from './user'
import type { Role } from './role'
import type { User } from './user'
export interface Company {
id: number
name: string
vat_id: string | null
tax_id: string | null
logo: string | null
logo_path: string | null
unique_hash: string
owner_id: number
slug: string
created_at: string
updated_at: string
address?: Address
owner?: User
roles: Role[]
}
export interface CompanySetting {
id: number
company_id: number
option: string
value: string | null
}
export interface CompanyInvitation {
id: number
company_id: number
email: string
token: string
status: CompanyInvitationStatus
expires_at: string
created_at: string
company?: Company
role?: Role
invited_by?: User
}
export enum CompanyInvitationStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
DECLINED = 'declined',
EXPIRED = 'expired',
}

View File

@@ -0,0 +1,32 @@
export interface Currency {
id: number
name: string
code: string
symbol: string
precision: number
thousand_separator: string
decimal_separator: string
swap_currency_symbol: boolean
exchange_rate: number
}
export interface ExchangeRateLog {
id: number
company_id: number
base_currency_id: number
currency_id: number
exchange_rate: number
created_at: string
updated_at: string
}
export interface ExchangeRateProvider {
id: number
key: string
driver: string
currencies: string[]
driver_config: Record<string, string>
company_id: number
active: boolean
company?: import('./company').Company
}

View File

@@ -0,0 +1,62 @@
import type { Company } from './company'
export type CustomFieldType =
| 'Text'
| 'Textarea'
| 'Phone'
| 'URL'
| 'Number'
| 'Dropdown'
| 'Switch'
| 'Date'
| 'Time'
| 'DateTime'
export type CustomFieldModelType =
| 'Customer'
| 'Invoice'
| 'Estimate'
| 'Payment'
| 'Expense'
export interface CustomField {
id: number
name: string
slug: string
label: string
model_type: CustomFieldModelType
type: CustomFieldType
placeholder: string | null
options: string[] | null
boolean_answer: boolean | null
date_answer: string | null
time_answer: string | null
string_answer: string | null
number_answer: number | null
date_time_answer: string | null
is_required: boolean
in_use: boolean
order: number | null
company_id: number
default_answer: string | boolean | number | null
company?: Company
}
export interface CustomFieldValue {
id: number
custom_field_valuable_type: string
custom_field_valuable_id: number
type: CustomFieldType
boolean_answer: boolean | null
date_answer: string | null
time_answer: string | null
string_answer: string | null
number_answer: number | null
date_time_answer: string | null
custom_field_id: number
company_id: number
default_answer: string | boolean | number | null
default_formatted_answer: string | null
custom_field?: CustomField
company?: Company
}

View File

@@ -0,0 +1,57 @@
import type { Currency } from './currency'
import type { Company } from './company'
import type { Address } from './user'
import type { CustomFieldValue } from './custom-field'
export interface Country {
id: number
code: string
name: string
phone_code: number
}
export interface Customer {
id: number
name: string
email: string | null
phone: string | null
contact_name: string | null
company_name: string | null
website: string | null
enable_portal: boolean
password_added: boolean
currency_id: number | null
company_id: number
facebook_id: string | null
google_id: string | null
github_id: string | null
created_at: string
updated_at: string
formatted_created_at: string
avatar: string | number
due_amount: number | null
base_due_amount: number | null
prefix: string | null
tax_id: string | null
billing?: Address
shipping?: Address
fields?: CustomFieldValue[]
company?: Company
currency?: Currency
}
export interface CreateCustomerPayload {
name: string
contact_name?: string
email?: string
phone?: string | null
password?: string
confirm_password?: string
currency_id: number | null
website?: string | null
billing?: Partial<Address>
shipping?: Partial<Address>
enable_portal?: boolean
customFields?: CustomFieldValue[]
fields?: CustomFieldValue[]
}

View File

@@ -0,0 +1,119 @@
import type { Customer } from './customer'
import type { User } from './user'
import type { Company } from './company'
import type { Currency } from './currency'
import type { Tax } from './tax'
import type { CustomFieldValue } from './custom-field'
import type { DiscountType } from './invoice'
export enum EstimateStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
VIEWED = 'VIEWED',
EXPIRED = 'EXPIRED',
ACCEPTED = 'ACCEPTED',
REJECTED = 'REJECTED',
}
export interface EstimateItem {
id: number | string
name: string
description: string | null
discount_type: DiscountType
quantity: number
unit_name: string | null
discount: number
discount_val: number
price: number
tax: number
total: number
item_id: number | null
estimate_id: number | null
company_id: number
exchange_rate: number
base_discount_val: number
base_price: number
base_tax: number
base_total: number
taxes?: Tax[]
fields?: CustomFieldValue[]
}
export interface Estimate {
id: number
estimate_date: string
expiry_date: string
estimate_number: string
status: EstimateStatus
reference_number: string | null
tax_per_item: string | null
tax_included: boolean | null
discount_per_item: string | null
notes: string | null
discount: number
discount_type: DiscountType
discount_val: number
sub_total: number
total: number
tax: number
unique_hash: string
creator_id: number
template_name: string | null
customer_id: number
exchange_rate: number
base_discount_val: number
base_sub_total: number
base_total: number
base_tax: number
sequence_number: number
currency_id: number
formatted_expiry_date: string
formatted_estimate_date: string
estimate_pdf_url: string
sales_tax_type: string | null
sales_tax_address_type: string | null
items?: EstimateItem[]
customer?: Customer
creator?: User
taxes?: Tax[]
fields?: CustomFieldValue[]
company?: Company
currency?: Currency
}
export interface CreateEstimatePayload {
estimate_date: string
expiry_date: string
estimate_number: string
reference_number?: string | null
customer_id: number
template_name?: string | null
notes?: string | null
discount_type?: DiscountType
discount?: number
discount_val?: number
tax_per_item?: string | null
tax_included?: boolean | null
discount_per_item?: string | null
sales_tax_type?: string | null
sales_tax_address_type?: string | null
items: CreateEstimateItemPayload[]
taxes?: Partial<Tax>[]
customFields?: CustomFieldValue[]
fields?: CustomFieldValue[]
}
export interface CreateEstimateItemPayload {
item_id?: number | null
name: string
description?: string | null
quantity: number
price: number
discount_type?: DiscountType
discount?: number
discount_val?: number
tax?: number
total?: number
taxes?: Partial<Tax>[]
unit_name?: string | null
}

View File

@@ -0,0 +1,72 @@
import type { Customer } from './customer'
import type { User } from './user'
import type { Company } from './company'
import type { Currency } from './currency'
import type { PaymentMethod } from './payment'
import type { CustomFieldValue } from './custom-field'
export interface ExpenseCategory {
id: number
name: string
description: string | null
company_id: number
amount: number | null
formatted_created_at: string
company?: Company
}
export interface ReceiptUrl {
url: string
type: string
}
export interface ReceiptMeta {
id: number
name: string
file_name: string
mime_type: string
size: number
disk: string
collection_name: string
}
export interface Expense {
id: number
expense_date: string
expense_number: string | null
amount: number
notes: string | null
customer_id: number | null
attachment_receipt_url: ReceiptUrl | null
attachment_receipt: string | null
attachment_receipt_meta: ReceiptMeta | null
company_id: number
expense_category_id: number | null
creator_id: number
formatted_expense_date: string
formatted_created_at: string
exchange_rate: number
currency_id: number
base_amount: number
payment_method_id: number | null
customer?: Customer
expense_category?: ExpenseCategory
creator?: User
fields?: CustomFieldValue[]
company?: Company
currency?: Currency
payment_method?: PaymentMethod
}
export interface CreateExpensePayload {
expense_date: string
amount: number
expense_category_id?: number | null
customer_id?: number | null
payment_method_id?: number | null
notes?: string | null
exchange_rate?: number
currency_id?: number
customFields?: CustomFieldValue[]
fields?: CustomFieldValue[]
}

View File

@@ -0,0 +1,79 @@
export type { Currency, ExchangeRateLog, ExchangeRateProvider } from './currency'
export type { Role, Ability } from './role'
export type { Company, CompanySetting, CompanyInvitation } from './company'
export { CompanyInvitationStatus } from './company'
export type { Address, User, UserSetting } from './user'
export { AddressType } from './user'
export type { Country, Customer, CreateCustomerPayload } from './customer'
export type {
Invoice,
InvoiceItem,
CreateInvoicePayload,
CreateInvoiceItemPayload,
DiscountType,
} from './invoice'
export { InvoiceStatus, InvoicePaidStatus } from './invoice'
export type {
Estimate,
EstimateItem,
CreateEstimatePayload,
CreateEstimateItemPayload,
} from './estimate'
export { EstimateStatus } from './estimate'
export type {
RecurringInvoice,
CreateRecurringInvoicePayload,
} from './recurring-invoice'
export {
RecurringInvoiceStatus,
RecurringInvoiceLimitBy,
} from './recurring-invoice'
export type {
Payment,
PaymentMethod,
Transaction,
CreatePaymentPayload,
} from './payment'
export type {
Expense,
ExpenseCategory,
ReceiptUrl,
ReceiptMeta,
CreateExpensePayload,
} from './expense'
export type { Item, Unit } from './item'
export type { TaxType, Tax } from './tax'
export { TaxTypeCategory } from './tax'
export type {
CustomField,
CustomFieldValue,
CustomFieldType,
CustomFieldModelType,
} from './custom-field'
export type { Note, NoteType } from './note'
export type {
Module,
InstalledModule,
ModuleAuthor,
ModuleVersion,
ModuleLink,
ModuleReview,
ModuleScreenshot,
ModuleFaq,
} from './module'
export type { Setting, CompanySettingsMap } from './setting'

View File

@@ -0,0 +1,135 @@
import type { Customer } from './customer'
import type { User } from './user'
import type { Company } from './company'
import type { Currency } from './currency'
import type { Tax } from './tax'
import type { CustomFieldValue } from './custom-field'
export enum InvoiceStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
VIEWED = 'VIEWED',
COMPLETED = 'COMPLETED',
}
export enum InvoicePaidStatus {
UNPAID = 'UNPAID',
PARTIALLY_PAID = 'PARTIALLY_PAID',
PAID = 'PAID',
}
export type DiscountType = 'fixed' | 'percentage'
export interface InvoiceItem {
id: number | string
name: string
description: string | null
discount_type: DiscountType
price: number
quantity: number
unit_name: string | null
discount: number
discount_val: number
tax: number
total: number
invoice_id: number | null
item_id: number | null
company_id: number
base_price: number
exchange_rate: number
base_discount_val: number
base_tax: number
base_total: number
recurring_invoice_id: number | null
taxes?: Tax[]
fields?: CustomFieldValue[]
}
export interface Invoice {
id: number
invoice_date: string
due_date: string
invoice_number: string
reference_number: string | null
status: InvoiceStatus
paid_status: InvoicePaidStatus
tax_per_item: string | null
tax_included: boolean | null
discount_per_item: string | null
notes: string | null
discount_type: DiscountType
discount: number
discount_val: number
sub_total: number
total: number
tax: number
due_amount: number
sent: boolean | null
viewed: boolean | null
unique_hash: string
template_name: string | null
customer_id: number
recurring_invoice_id: number | null
sequence_number: number
exchange_rate: number
base_discount_val: number
base_sub_total: number
base_total: number
creator_id: number
base_tax: number
base_due_amount: number
currency_id: number
formatted_created_at: string
invoice_pdf_url: string
formatted_invoice_date: string
formatted_due_date: string
allow_edit: boolean
payment_module_enabled: boolean
sales_tax_type: string | null
sales_tax_address_type: string | null
overdue: boolean | null
items?: InvoiceItem[]
customer?: Customer
creator?: User
taxes?: Tax[]
fields?: CustomFieldValue[]
company?: Company
currency?: Currency
}
export interface CreateInvoicePayload {
invoice_date: string
due_date: string
invoice_number: string
reference_number?: string | null
customer_id: number
template_name?: string | null
notes?: string | null
discount_type?: DiscountType
discount?: number
discount_val?: number
tax_per_item?: string | null
tax_included?: boolean | null
discount_per_item?: string | null
sales_tax_type?: string | null
sales_tax_address_type?: string | null
items: CreateInvoiceItemPayload[]
taxes?: Partial<Tax>[]
customFields?: CustomFieldValue[]
fields?: CustomFieldValue[]
}
export interface CreateInvoiceItemPayload {
item_id?: number | null
name: string
description?: string | null
quantity: number
price: number
discount_type?: DiscountType
discount?: number
discount_val?: number
tax?: number
total?: number
taxes?: Partial<Tax>[]
unit_name?: string | null
}

View File

@@ -0,0 +1,29 @@
import type { Company } from './company'
import type { Currency } from './currency'
import type { Tax } from './tax'
export interface Unit {
id: number
name: string
company_id: number
company?: Company
}
export interface Item {
id: number
name: string
description: string | null
price: number
unit_id: number | null
company_id: number
creator_id: number
currency_id: number | null
created_at: string
updated_at: string
tax_per_item: string | null
formatted_created_at: string
unit?: Unit
company?: Company
taxes?: Tax[]
currency?: Currency
}

View File

@@ -0,0 +1,76 @@
export interface ModuleAuthor {
name: string
avatar: string
}
export interface ModuleVersion {
module_version: string
invoiceshelf_version: string
created_at: string
}
export interface ModuleLink {
name: string
url: string
}
export interface ModuleReview {
id: number
rating: number
comment: string
user: string
created_at: string
}
export interface ModuleScreenshot {
url: string
title: string | null
}
export interface ModuleFaq {
question: string
answer: string
}
export interface Module {
id: number
average_rating: number | null
cover: string | null
slug: string
module_name: string
faq: ModuleFaq[] | null
highlights: string[] | null
installed_module_version: string | null
installed_module_version_updated_at: string | null
latest_module_version: string
latest_module_version_updated_at: string
is_dev: boolean
license: string | null
long_description: string | null
monthly_price: number | null
name: string
purchased: boolean
reviews: ModuleReview[]
screenshots: ModuleScreenshot[] | null
short_description: string | null
type: string | null
yearly_price: number | null
author_name: string
author_avatar: string
installed: boolean
enabled: boolean
update_available: boolean
video_link: string | null
video_thumbnail: string | null
links: ModuleLink[] | null
}
export interface InstalledModule {
id: number
name: string
version: string
installed: boolean
enabled: boolean
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,12 @@
import type { Company } from './company'
export type NoteType = 'Invoice' | 'Estimate' | 'Payment'
export interface Note {
id: number
type: NoteType
name: string
notes: string
is_default: boolean | null
company?: Company
}

View File

@@ -0,0 +1,67 @@
import type { Customer } from './customer'
import type { Invoice } from './invoice'
import type { Company } from './company'
import type { Currency } from './currency'
import type { User } from './user'
import type { CustomFieldValue } from './custom-field'
export interface PaymentMethod {
id: number
name: string
company_id: number
type: string | null
company?: Company
}
export interface Transaction {
id: number
transaction_id: string
type: string
status: string
transaction_date: string
invoice_id: number | null
invoice?: Invoice
company?: Company
}
export interface Payment {
id: number
payment_number: string
payment_date: string
notes: string | null
amount: number
unique_hash: string
invoice_id: number | null
company_id: number
payment_method_id: number | null
creator_id: number
customer_id: number
exchange_rate: number
base_amount: number
currency_id: number
transaction_id: number | null
sequence_number: number
formatted_created_at: string
formatted_payment_date: string
payment_pdf_url: string
customer?: Customer
invoice?: Invoice
payment_method?: PaymentMethod
fields?: CustomFieldValue[]
company?: Company
currency?: Currency
transaction?: Transaction
}
export interface CreatePaymentPayload {
payment_date: string
payment_number: string
customer_id: number
amount: number
invoice_id?: number | null
payment_method_id?: number | null
notes?: string | null
exchange_rate?: number
customFields?: CustomFieldValue[]
fields?: CustomFieldValue[]
}

View File

@@ -0,0 +1,86 @@
import type { Customer } from './customer'
import type { User } from './user'
import type { Company } from './company'
import type { Currency } from './currency'
import type { Tax } from './tax'
import type { Invoice, InvoiceItem } from './invoice'
import type { CustomFieldValue } from './custom-field'
import type { DiscountType } from './invoice'
export enum RecurringInvoiceStatus {
ACTIVE = 'ACTIVE',
ON_HOLD = 'ON_HOLD',
COMPLETED = 'COMPLETED',
}
export enum RecurringInvoiceLimitBy {
NONE = 'NONE',
COUNT = 'COUNT',
DATE = 'DATE',
}
export interface RecurringInvoice {
id: number
starts_at: string
formatted_starts_at: string
formatted_created_at: string
formatted_next_invoice_at: string
formatted_limit_date: string
send_automatically: boolean
customer_id: number
company_id: number
creator_id: number
status: RecurringInvoiceStatus
next_invoice_at: string
frequency: string
limit_by: RecurringInvoiceLimitBy
limit_count: number | null
limit_date: string | null
exchange_rate: number
tax_per_item: string | null
tax_included: boolean | null
discount_per_item: string | null
notes: string | null
discount_type: DiscountType
discount: number
discount_val: number
sub_total: number
total: number
tax: number
due_amount: number
template_name: string | null
sales_tax_type: string | null
sales_tax_address_type: string | null
fields?: CustomFieldValue[]
items?: InvoiceItem[]
customer?: Customer
company?: Company
invoices?: Invoice[]
taxes?: Tax[]
creator?: User
currency?: Currency
}
export interface CreateRecurringInvoicePayload {
starts_at: string
frequency: string
customer_id: number
send_automatically?: boolean
limit_by?: RecurringInvoiceLimitBy
limit_count?: number | null
limit_date?: string | null
template_name?: string | null
notes?: string | null
discount_type?: DiscountType
discount?: number
discount_val?: number
tax_per_item?: string | null
tax_included?: boolean | null
discount_per_item?: string | null
sales_tax_type?: string | null
sales_tax_address_type?: string | null
items: Partial<InvoiceItem>[]
taxes?: Partial<Tax>[]
customFields?: CustomFieldValue[]
fields?: CustomFieldValue[]
}

View File

@@ -0,0 +1,20 @@
export interface Ability {
id: number
name: string
title: string | null
entity_id: number | null
entity_type: string | null
only_owned: boolean
scope: number | null
created_at: string
updated_at: string
}
export interface Role {
id: number
name: string
title: string | null
level: number | null
formatted_created_at: string
abilities: Ability[]
}

View File

@@ -0,0 +1,58 @@
/**
* Global application setting (not company-scoped).
* Corresponds to the `settings` table.
*/
export interface Setting {
id: number
option: string
value: string | null
}
/**
* Common company settings that are frequently accessed on the frontend.
* These correspond to key-value pairs in the company_settings table.
* Use this typed map when reading settings from the store, rather than
* accessing raw CompanySetting rows.
*/
export interface CompanySettingsMap {
currency: string
time_zone: string
language: string
fiscal_year: string
carbon_date_format: string
carbon_time_format: string
moment_date_format: string
notification_email: string
tax_per_item: 'YES' | 'NO'
discount_per_item: 'YES' | 'NO'
invoice_prefix: string
invoice_auto_generate: string
estimate_prefix: string
estimate_auto_generate: string
payment_prefix: string
payment_auto_generate: string
invoice_mail_body: string
estimate_mail_body: string
payment_mail_body: string
invoice_company_address_format: string
invoice_shipping_address_format: string
invoice_billing_address_format: string
estimate_company_address_format: string
estimate_shipping_address_format: string
estimate_billing_address_format: string
payment_company_address_format: string
payment_from_customer_address_format: string
invoice_email_attachment: 'YES' | 'NO'
estimate_email_attachment: 'YES' | 'NO'
payment_email_attachment: 'YES' | 'NO'
retrospective_edits: string
invoice_set_due_date_automatically: 'YES' | 'NO'
invoice_due_date_days: string
estimate_set_expiry_date_automatically: 'YES' | 'NO'
estimate_expiry_date_days: string
estimate_convert_action: string
invoice_use_time: 'YES' | 'NO'
sales_tax_type: string | null
sales_tax_address_type: string | null
[key: string]: string | null
}

View File

@@ -0,0 +1,44 @@
import type { Currency } from './currency'
import type { Company } from './company'
export enum TaxTypeCategory {
GENERAL = 'GENERAL',
MODULE = 'MODULE',
}
export interface TaxType {
id: number
name: string
percent: number
fixed_amount: number
calculation_type: string | null
type: TaxTypeCategory
compound_tax: boolean
collective_tax: number | null
description: string | null
company_id: number
company?: Company
}
export interface Tax {
id: number
tax_type_id: number
invoice_id: number | null
estimate_id: number | null
invoice_item_id: number | null
estimate_item_id: number | null
item_id: number | null
company_id: number
name: string
amount: number
percent: number
calculation_type: string | null
fixed_amount: number
compound_tax: boolean
base_amount: number
currency_id: number | null
type: TaxTypeCategory
recurring_invoice_id: number | null
tax_type?: TaxType
currency?: Currency
}

View File

@@ -0,0 +1,60 @@
import type { Currency } from './currency'
import type { Company } from './company'
import type { Role } from './role'
import type { Country } from './customer'
export interface Address {
id: number
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
fax: string | null
type: AddressType
user_id: number | null
company_id: number | null
customer_id: number | null
country?: Country
user?: User
}
export enum AddressType {
BILLING = 'billing',
SHIPPING = 'shipping',
}
export interface User {
id: number
name: string
email: string
phone: string | null
role: string | null
contact_name: string | null
company_name: string | null
website: string | null
enable_portal: boolean | null
currency_id: number | null
facebook_id: string | null
google_id: string | null
github_id: string | null
created_at: string
updated_at: string
avatar: string | number
is_owner: boolean
is_super_admin: boolean
roles: Role[]
formatted_created_at: string
currency?: Currency
companies?: Company[]
}
export interface UserSetting {
id: number
key: string
value: string | null
user_id: number
}

9
resources/scripts-v2/types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,78 @@
export type {
ApiResponse,
PaginatedResponse,
PaginationMeta,
ApiError,
ListParams,
DateRangeParams,
NextNumberResponse,
DeletePayload,
} from './api'
export type {
Currency,
ExchangeRateLog,
ExchangeRateProvider,
Role,
Ability,
Company,
CompanySetting,
CompanyInvitation,
Address,
User,
UserSetting,
Country,
Customer,
CreateCustomerPayload,
Invoice,
InvoiceItem,
CreateInvoicePayload,
CreateInvoiceItemPayload,
DiscountType,
Estimate,
EstimateItem,
CreateEstimatePayload,
CreateEstimateItemPayload,
RecurringInvoice,
CreateRecurringInvoicePayload,
Payment,
PaymentMethod,
Transaction,
CreatePaymentPayload,
Expense,
ExpenseCategory,
ReceiptUrl,
ReceiptMeta,
CreateExpensePayload,
Item,
Unit,
TaxType,
Tax,
CustomField,
CustomFieldValue,
CustomFieldType,
CustomFieldModelType,
Note,
NoteType,
Module,
InstalledModule,
ModuleAuthor,
ModuleVersion,
ModuleLink,
ModuleReview,
ModuleScreenshot,
ModuleFaq,
Setting,
CompanySettingsMap,
} from './domain'
export {
CompanyInvitationStatus,
AddressType,
InvoiceStatus,
InvoicePaidStatus,
EstimateStatus,
RecurringInvoiceStatus,
RecurringInvoiceLimitBy,
TaxTypeCategory,
} from './domain'

View File

@@ -0,0 +1,9 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>
export default component
}

View File

@@ -0,0 +1,165 @@
import type { ApiError } from '../types/api'
/**
* Shape of an Axios-like error response.
*/
interface AxiosLikeError {
response?: {
status?: number
statusText?: string
data?: {
message?: string
error?: string | boolean
errors?: Record<string, string[]>
}
}
message?: string
}
/**
* Normalized API error result.
*/
export interface NormalizedApiError {
message: string
statusCode: number | null
validationErrors: Record<string, string[]>
isUnauthorized: boolean
isValidationError: boolean
isNetworkError: boolean
}
/**
* Known error message to translation key map.
*/
const ERROR_TRANSLATION_MAP: Record<string, string> = {
'These credentials do not match our records.': 'errors.login_invalid_credentials',
'invalid_key': 'errors.invalid_provider_key',
'This feature is available on Starter plan and onwards!': 'errors.starter_plan',
'taxes_attached': 'settings.tax_types.already_in_use',
'expense_attached': 'settings.expense_category.already_in_use',
'payments_attached': 'settings.payment_modes.payments_attached',
'expenses_attached': 'settings.payment_modes.expenses_attached',
'role_attached_to_users': 'settings.roles.already_in_use',
'items_attached': 'settings.customization.items.already_in_use',
'payment_attached_message': 'invoices.payment_attached_message',
'The email has already been taken.': 'validation.email_already_taken',
'Relation estimateItems exists.': 'items.item_attached_message',
'Relation invoiceItems exists.': 'items.item_attached_message',
'Relation taxes exists.': 'settings.tax_types.already_in_use',
'Relation payments exists.': 'errors.payment_attached',
'The estimate number has already been taken.': 'errors.estimate_number_used',
'The payment number has already been taken.': 'errors.estimate_number_used',
'The invoice number has already been taken.': 'errors.invoice_number_used',
'The name has already been taken.': 'errors.name_already_taken',
'total_invoice_amount_must_be_more_than_paid_amount': 'invoices.invalid_due_amount_message',
'you_cannot_edit_currency': 'customers.edit_currency_not_allowed',
'receipt_does_not_exist': 'errors.receipt_does_not_exist',
'customer_cannot_be_changed_after_payment_is_added': 'errors.customer_cannot_be_changed_after_payment_is_added',
'invalid_credentials': 'errors.invalid_credentials',
'not_allowed': 'errors.not_allowed',
'invalid_state': 'errors.invalid_state',
'invalid_city': 'errors.invalid_city',
'invalid_postal_code': 'errors.invalid_postal_code',
'invalid_format': 'errors.invalid_format',
'api_error': 'errors.api_error',
'feature_not_enabled': 'errors.feature_not_enabled',
'request_limit_met': 'errors.request_limit_met',
'address_incomplete': 'errors.address_incomplete',
'invalid_address': 'errors.invalid_address',
'Email could not be sent to this email address.': 'errors.email_could_not_be_sent',
}
/**
* Handle an API error and return a normalized error object.
*
* @param err - The error from an API call (typically an Axios error)
* @returns A normalized error with extracted message, status, and validation errors
*/
export function handleApiError(err: unknown): NormalizedApiError {
const axiosError = err as AxiosLikeError
if (!axiosError.response) {
return {
message: 'Please check your internet connection or wait until servers are back online.',
statusCode: null,
validationErrors: {},
isUnauthorized: false,
isValidationError: false,
isNetworkError: true,
}
}
const { response } = axiosError
const statusCode = response.status ?? null
const isUnauthorized =
response.statusText === 'Unauthorized' ||
response.data?.message === ' Unauthorized.' ||
statusCode === 401
if (isUnauthorized) {
const message = response.data?.message ?? 'Unauthorized'
return {
message,
statusCode,
validationErrors: {},
isUnauthorized: true,
isValidationError: false,
isNetworkError: false,
}
}
const validationErrors = response.data?.errors ?? {}
const isValidationError = Object.keys(validationErrors).length > 0
if (isValidationError) {
const firstErrorKey = Object.keys(validationErrors)[0]
const firstErrorMessage = validationErrors[firstErrorKey]?.[0] ?? 'Validation error'
return {
message: firstErrorMessage,
statusCode,
validationErrors,
isUnauthorized: false,
isValidationError: true,
isNetworkError: false,
}
}
const errorField = response.data?.error
let message: string
if (typeof errorField === 'string') {
message = errorField
} else {
message = response.data?.message ?? 'An unexpected error occurred'
}
return {
message,
statusCode,
validationErrors: {},
isUnauthorized: false,
isValidationError: false,
isNetworkError: false,
}
}
/**
* Extract validation errors from an API error response.
*
* @param err - The error from an API call
* @returns A record mapping field names to arrays of error messages
*/
export function extractValidationErrors(err: unknown): Record<string, string[]> {
const axiosError = err as AxiosLikeError
return axiosError.response?.data?.errors ?? {}
}
/**
* Look up the translation key for a known error message.
*
* @param errorMessage - The raw error message
* @returns The translation key if known, or null if not mapped
*/
export function getErrorTranslationKey(errorMessage: string): string | null {
return ERROR_TRANSLATION_MAP[errorMessage] ?? null
}

View File

@@ -0,0 +1,105 @@
import { format, formatDistanceToNow, parseISO, isValid } from 'date-fns'
import type { Locale } from 'date-fns'
/**
* Default date format used across the application.
*/
export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd'
/**
* Default datetime format used across the application.
*/
export const DEFAULT_DATETIME_FORMAT = 'yyyy-MM-dd HH:mm:ss'
/**
* Format a date value into a string using the given format pattern.
*
* @param date - A Date object, ISO string, or timestamp
* @param formatStr - A date-fns format pattern (default: 'yyyy-MM-dd')
* @param options - Optional locale for localized formatting
* @returns Formatted date string, or empty string if invalid
*/
export function formatDate(
date: Date | string | number,
formatStr: string = DEFAULT_DATE_FORMAT,
options?: { locale?: Locale }
): string {
const parsed = normalizeDate(date)
if (!parsed || !isValid(parsed)) {
return ''
}
return format(parsed, formatStr, options)
}
/**
* Get a human-readable relative time string (e.g. "3 days ago").
*
* @param date - A Date object, ISO string, or timestamp
* @param options - Optional settings for suffix and locale
* @returns Relative time string, or empty string if invalid
*/
export function relativeTime(
date: Date | string | number,
options?: { addSuffix?: boolean; locale?: Locale }
): string {
const parsed = normalizeDate(date)
if (!parsed || !isValid(parsed)) {
return ''
}
return formatDistanceToNow(parsed, {
addSuffix: options?.addSuffix ?? true,
locale: options?.locale,
})
}
/**
* Parse a date string or value into a Date object.
*
* @param date - A Date object, ISO string, or timestamp
* @returns A valid Date object, or null if parsing fails
*/
export function parseDate(date: Date | string | number): Date | null {
const parsed = normalizeDate(date)
if (!parsed || !isValid(parsed)) {
return null
}
return parsed
}
/**
* Check whether a given date value is valid.
*
* @param date - A Date object, ISO string, or timestamp
* @returns True if the date is valid
*/
export function isValidDate(date: Date | string | number): boolean {
const parsed = normalizeDate(date)
return parsed !== null && isValid(parsed)
}
/**
* Normalize various date input types into a Date object.
*/
function normalizeDate(date: Date | string | number): Date | null {
if (date instanceof Date) {
return date
}
if (typeof date === 'string') {
const parsed = parseISO(date)
return isValid(parsed) ? parsed : null
}
if (typeof date === 'number') {
const parsed = new Date(date)
return isValid(parsed) ? parsed : null
}
return null
}

View File

@@ -0,0 +1,96 @@
export interface CurrencyConfig {
precision: number
thousand_separator: string
decimal_separator: string
symbol: string
swap_currency_symbol?: boolean
}
const DEFAULT_CURRENCY: CurrencyConfig = {
precision: 2,
thousand_separator: ',',
decimal_separator: '.',
symbol: '$',
swap_currency_symbol: false,
}
/**
* Format an amount in cents to a currency string with symbol and separators.
*
* @param amountInCents - The amount in cents (e.g. 10050 = $100.50)
* @param currency - Currency configuration for formatting
* @returns Formatted currency string (e.g. "$ 100.50")
*/
export function formatMoney(
amountInCents: number,
currency: CurrencyConfig = DEFAULT_CURRENCY
): string {
let amount = amountInCents / 100
const {
symbol,
swap_currency_symbol = false,
} = currency
let precision = Math.abs(currency.precision)
if (Number.isNaN(precision)) {
precision = 2
}
const negativeSign = amount < 0 ? '-' : ''
amount = Math.abs(Number(amount) || 0)
const fixedAmount = amount.toFixed(precision)
const integerPart = parseInt(fixedAmount, 10).toString()
const remainder = integerPart.length > 3 ? integerPart.length % 3 : 0
const thousandText = remainder
? integerPart.substring(0, remainder) + currency.thousand_separator
: ''
const amountText = integerPart
.substring(remainder)
.replace(/(\d{3})(?=\d)/g, '$1' + currency.thousand_separator)
const precisionText = precision
? currency.decimal_separator +
Math.abs(amount - parseInt(fixedAmount, 10))
.toFixed(precision)
.slice(2)
: ''
const combinedAmountText =
negativeSign + thousandText + amountText + precisionText
const moneySymbol = `${symbol}`
return swap_currency_symbol
? `${combinedAmountText} ${moneySymbol}`
: `${moneySymbol} ${combinedAmountText}`
}
/**
* Parse a formatted currency string back to cents.
*
* @param formattedAmount - The formatted string (e.g. "$ 1,234.56")
* @param currency - Currency configuration used for parsing
* @returns Amount in cents
*/
export function parseMoneyCents(
formattedAmount: string,
currency: CurrencyConfig = DEFAULT_CURRENCY
): number {
const cleaned = formattedAmount
.replace(currency.symbol, '')
.replace(new RegExp(`\\${currency.thousand_separator}`, 'g'), '')
.replace(currency.decimal_separator, '.')
.trim()
const parsed = parseFloat(cleaned)
if (Number.isNaN(parsed)) {
return 0
}
return Math.round(parsed * 100)
}

View File

@@ -0,0 +1,29 @@
export {
formatMoney,
parseMoneyCents,
} from './format-money'
export type { CurrencyConfig } from './format-money'
export {
formatDate,
relativeTime,
parseDate,
isValidDate,
DEFAULT_DATE_FORMAT,
DEFAULT_DATETIME_FORMAT,
} from './format-date'
export {
get as lsGet,
set as lsSet,
remove as lsRemove,
has as lsHas,
clear as lsClear,
} from './local-storage'
export {
handleApiError,
extractValidationErrors,
getErrorTranslationKey,
} from './error-handling'
export type { NormalizedApiError } from './error-handling'

View File

@@ -0,0 +1,66 @@
/**
* Typed wrapper around localStorage for safe get/set/remove operations.
* Handles JSON serialization and deserialization automatically.
*/
/**
* Retrieve a value from localStorage, parsed from JSON.
*
* @param key - The localStorage key
* @returns The parsed value, or null if the key does not exist or parsing fails
*/
export function get<T>(key: string): T | null {
const raw = localStorage.getItem(key)
if (raw === null) {
return null
}
try {
return JSON.parse(raw) as T
} catch {
// If parsing fails, return the raw string cast to T.
// This handles cases where the value is a plain string not wrapped in quotes.
return raw as unknown as T
}
}
/**
* Store a value in localStorage as JSON.
*
* @param key - The localStorage key
* @param value - The value to store (will be JSON-serialized)
*/
export function set<T>(key: string, value: T): void {
if (typeof value === 'string') {
localStorage.setItem(key, value)
} else {
localStorage.setItem(key, JSON.stringify(value))
}
}
/**
* Remove a key from localStorage.
*
* @param key - The localStorage key to remove
*/
export function remove(key: string): void {
localStorage.removeItem(key)
}
/**
* Check whether a key exists in localStorage.
*
* @param key - The localStorage key
* @returns True if the key exists
*/
export function has(key: string): boolean {
return localStorage.getItem(key) !== null
}
/**
* Clear all entries in localStorage.
*/
export function clear(): void {
localStorage.clear()
}