diff --git a/package.json b/package.json index 7330e6b0..b8f8324b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.0.0", + "@types/lodash": "^4.17.24", "@vitejs/plugin-vue": "^6.0.0", "@vue/compiler-sfc": "^3.5.13", "eslint": "^10.0.0", @@ -22,7 +23,9 @@ "eslint-plugin-vue": "^10.0.0", "prettier": "^3.4.2", "tailwind-scrollbar": "^4.0.0", - "tailwindcss": "^4.0.0" + "tailwindcss": "^4.0.0", + "typescript": "^6.0.2", + "vue-tsc": "^3.2.6" }, "dependencies": { "@headlessui/vue": "^1.7.23", @@ -41,6 +44,7 @@ "@vueuse/core": "^14.0.0", "axios": "1.14.0", "chart.js": "^4.5.1", + "date-fns": "^4.1.0", "guid": "^0.0.12", "laravel-vite-plugin": "^3.0.0", "lodash": "^4.17.21", diff --git a/resources/scripts-v2/api/client.ts b/resources/scripts-v2/api/client.ts new file mode 100644 index 00000000..8fbfdb73 --- /dev/null +++ b/resources/scripts-v2/api/client.ts @@ -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 } diff --git a/resources/scripts-v2/api/endpoints.ts b/resources/scripts-v2/api/endpoints.ts new file mode 100644 index 00000000..b4fed517 --- /dev/null +++ b/resources/scripts-v2/api/endpoints.ts @@ -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 diff --git a/resources/scripts-v2/api/index.ts b/resources/scripts-v2/api/index.ts new file mode 100644 index 00000000..b3937745 --- /dev/null +++ b/resources/scripts-v2/api/index.ts @@ -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' diff --git a/resources/scripts-v2/api/services/auth.service.ts b/resources/scripts-v2/api/services/auth.service.ts new file mode 100644 index 00000000..2dde66e0 --- /dev/null +++ b/resources/scripts-v2/api/services/auth.service.ts @@ -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 { + await client.get(API.CSRF_COOKIE) + }, + + async login(payload: LoginPayload): Promise> { + await client.get(API.CSRF_COOKIE) + const { data } = await client.post(API.LOGIN, payload) + return data + }, + + async logout(): Promise { + await client.post(API.LOGOUT) + }, + + async forgotPassword(payload: ForgotPasswordPayload): Promise> { + const { data } = await client.post(API.FORGOT_PASSWORD, payload) + return data + }, + + async resetPassword(payload: ResetPasswordPayload): Promise> { + const { data } = await client.post(API.RESET_PASSWORD, payload) + return data + }, + + async check(): Promise> { + const { data } = await client.get(API.AUTH_CHECK) + return data + }, + + async getInvitationDetails(token: string): Promise> { + const { data } = await client.get(`${API.INVITATION_DETAILS}/${token}/details`) + return data + }, + + async registerWithInvitation(payload: RegisterWithInvitationPayload): Promise> { + const { data } = await client.post(API.REGISTER_WITH_INVITATION, payload) + return data + }, +} diff --git a/resources/scripts-v2/api/services/backup.service.ts b/resources/scripts-v2/api/services/backup.service.ts new file mode 100644 index 00000000..b8ba2e50 --- /dev/null +++ b/resources/scripts-v2/api/services/backup.service.ts @@ -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> { + 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 { + const { data } = await client.get(API.DOWNLOAD_BACKUP, { + params, + responseType: 'blob', + }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/bootstrap.service.ts b/resources/scripts-v2/api/services/bootstrap.service.ts new file mode 100644 index 00000000..a12736f2 --- /dev/null +++ b/resources/scripts-v2/api/services/bootstrap.service.ts @@ -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 + current_user_abilities: Ability[] + companies: Company[] + current_company: Company | null + current_company_settings: Record + current_company_currency: Currency | null + main_menu: MenuItem[] + setting_menu: MenuItem[] + config: Record + global_settings: Record + 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 { + const url = adminMode ? `${API.BOOTSTRAP}?admin_mode=1` : API.BOOTSTRAP + const { data } = await client.get(url) + return data + }, + + async getCurrentCompany(): Promise { + const { data } = await client.get(API.CURRENT_COMPANY) + return data + }, +} diff --git a/resources/scripts-v2/api/services/company.service.ts b/resources/scripts-v2/api/services/company.service.ts new file mode 100644 index 00000000..ff379c4e --- /dev/null +++ b/resources/scripts-v2/api/services/company.service.ts @@ -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 +} + +export interface CreateCompanyPayload { + name: string + currency?: number + address?: Record +} + +export const companyService = { + async update(payload: UpdateCompanyPayload): Promise> { + const { data } = await client.put(API.COMPANY, payload) + return data + }, + + async uploadLogo(payload: FormData): Promise> { + const { data } = await client.post(API.COMPANY_UPLOAD_LOGO, payload) + return data + }, + + async getSettings(settings?: string[]): Promise> { + 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> { + const { data } = await client.post(API.COMPANIES, payload) + return data + }, + + async listUserCompanies(): Promise> { + 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> { + const { data } = await client.get(API.COMPANY_MAIL_DEFAULT_CONFIG) + return data + }, + + async getMailConfig(): Promise> { + const { data } = await client.get(API.COMPANY_MAIL_CONFIG) + return data + }, + + async saveMailConfig(payload: Record): Promise<{ success: boolean }> { + const { data } = await client.post(API.COMPANY_MAIL_CONFIG, payload) + return data + }, + + async testMailConfig(payload: Record): Promise<{ success: boolean }> { + const { data } = await client.post(API.COMPANY_MAIL_TEST, payload) + return data + }, +} diff --git a/resources/scripts-v2/api/services/custom-field.service.ts b/resources/scripts-v2/api/services/custom-field.service.ts new file mode 100644 index 00000000..eed57f77 --- /dev/null +++ b/resources/scripts-v2/api/services/custom-field.service.ts @@ -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> { + const { data } = await client.get(API.CUSTOM_FIELDS, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.CUSTOM_FIELDS}/${id}`) + return data + }, + + async create(payload: CreateCustomFieldPayload): Promise> { + const { data } = await client.post(API.CUSTOM_FIELDS, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/customer.service.ts b/resources/scripts-v2/api/services/customer.service.ts new file mode 100644 index 00000000..9d65e505 --- /dev/null +++ b/resources/scripts-v2/api/services/customer.service.ts @@ -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 { + const { data } = await client.get(API.CUSTOMERS, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.CUSTOMERS}/${id}`) + return data + }, + + async create(payload: CreateCustomerPayload): Promise> { + const { data } = await client.post(API.CUSTOMERS, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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): Promise> { + const { data } = await client.get(`${API.CUSTOMER_STATS}/${id}/stats`, { params }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/dashboard.service.ts b/resources/scripts-v2/api/services/dashboard.service.ts new file mode 100644 index 00000000..6f026ecf --- /dev/null +++ b/resources/scripts-v2/api/services/dashboard.service.ts @@ -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 { + const { data } = await client.get(API.DASHBOARD, { params }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/disk.service.ts b/resources/scripts-v2/api/services/disk.service.ts new file mode 100644 index 00000000..84d1d55a --- /dev/null +++ b/resources/scripts-v2/api/services/disk.service.ts @@ -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 + 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> { + const { data } = await client.get(API.DISKS, { params }) + return data + }, + + async get(disk: string): Promise> { + const { data } = await client.get(`${API.DISKS}/${disk}`) + return data + }, + + async create(payload: CreateDiskPayload): Promise { + const { data } = await client.post(API.DISKS, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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 { + const { data } = await client.get(API.DISK_DRIVERS) + return data + }, +} diff --git a/resources/scripts-v2/api/services/estimate.service.ts b/resources/scripts-v2/api/services/estimate.service.ts new file mode 100644 index 00000000..bc940f39 --- /dev/null +++ b/resources/scripts-v2/api/services/estimate.service.ts @@ -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 { + const { data } = await client.get(API.ESTIMATES, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.ESTIMATES}/${id}`) + return data + }, + + async create(payload: CreateEstimatePayload): Promise> { + const { data } = await client.post(API.ESTIMATES, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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> { + const { data } = await client.post(`${API.ESTIMATES}/${payload.id}/send`, payload) + return data + }, + + async sendPreview(id: number, params?: Record): Promise> { + const { data } = await client.get(`${API.ESTIMATES}/${id}/send/preview`, { params }) + return data + }, + + async clone(id: number): Promise> { + const { data } = await client.post(`${API.ESTIMATES}/${id}/clone`) + return data + }, + + async changeStatus(payload: EstimateStatusPayload): Promise> { + const { data } = await client.post(`${API.ESTIMATES}/${payload.id}/status`, payload) + return data + }, + + async convertToInvoice(id: number): Promise> { + const { data } = await client.post(`${API.ESTIMATES}/${id}/convert-to-invoice`) + return data + }, + + async getNextNumber(params?: { key?: string }): Promise { + const { data } = await client.get(API.NEXT_NUMBER, { params: { key: 'estimate', ...params } }) + return data + }, + + async getTemplates(): Promise { + const { data } = await client.get(API.ESTIMATE_TEMPLATES) + return data + }, +} diff --git a/resources/scripts-v2/api/services/exchange-rate.service.ts b/resources/scripts-v2/api/services/exchange-rate.service.ts new file mode 100644 index 00000000..58524320 --- /dev/null +++ b/resources/scripts-v2/api/services/exchange-rate.service.ts @@ -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 +} + +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> { + const { data } = await client.get(API.EXCHANGE_RATE_PROVIDERS, { params }) + return data + }, + + async getProvider(id: number): Promise> { + const { data } = await client.get(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`) + return data + }, + + async createProvider(payload: CreateExchangeRateProviderPayload): Promise> { + const { data } = await client.post(API.EXCHANGE_RATE_PROVIDERS, payload) + return data + }, + + async updateProvider( + id: number, + payload: Partial, + ): Promise> { + 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 { + const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/exchange-rate`) + return data + }, + + async getActiveProvider(currencyId: number): Promise { + const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/active-provider`) + return data + }, + + // Currency lists + async getSupportedCurrencies(): Promise { + const { data } = await client.get(API.SUPPORTED_CURRENCIES) + return data + }, + + async getUsedCurrencies(): Promise { + const { data } = await client.get(API.USED_CURRENCIES) + return data + }, + + async getBulkCurrencies(): Promise { + 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 { + const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } }) + return data + }, + + async getCurrencyConverterServers(): Promise> { + const { data } = await client.get(API.CONFIG, { params: { key: 'currency_converter_servers' } }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/expense.service.ts b/resources/scripts-v2/api/services/expense.service.ts new file mode 100644 index 00000000..fa83794f --- /dev/null +++ b/resources/scripts-v2/api/services/expense.service.ts @@ -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 { + const { data } = await client.get(API.EXPENSES, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.EXPENSES}/${id}`) + return data + }, + + async create(payload: FormData): Promise> { + const { data } = await client.post(API.EXPENSES, payload) + return data + }, + + async update(id: number, payload: FormData): Promise> { + 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 { + const { data } = await client.get(`${API.EXPENSES}/${id}/show/receipt`, { + responseType: 'blob', + }) + return data + }, + + async uploadReceipt(id: number, payload: FormData): Promise> { + const { data } = await client.post(`${API.EXPENSES}/${id}/upload/receipts`, payload) + return data + }, + + // Expense Categories + async listCategories(params?: ListParams): Promise> { + const { data } = await client.get(API.CATEGORIES, { params }) + return data + }, + + async getCategory(id: number): Promise> { + const { data } = await client.get(`${API.CATEGORIES}/${id}`) + return data + }, + + async createCategory(payload: CreateExpenseCategoryPayload): Promise> { + const { data } = await client.post(API.CATEGORIES, payload) + return data + }, + + async updateCategory( + id: number, + payload: CreateExpenseCategoryPayload, + ): Promise> { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/index.ts b/resources/scripts-v2/api/services/index.ts new file mode 100644 index 00000000..8e09e39a --- /dev/null +++ b/resources/scripts-v2/api/services/index.ts @@ -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' diff --git a/resources/scripts-v2/api/services/invoice.service.ts b/resources/scripts-v2/api/services/invoice.service.ts new file mode 100644 index 00000000..f3ca8509 --- /dev/null +++ b/resources/scripts-v2/api/services/invoice.service.ts @@ -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 { + const { data } = await client.get(API.INVOICES, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.INVOICES}/${id}`) + return data + }, + + async create(payload: CreateInvoicePayload): Promise> { + const { data } = await client.post(API.INVOICES, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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> { + const { data } = await client.post(`${API.INVOICES}/${payload.id}/send`, payload) + return data + }, + + async sendPreview(params: SendPreviewParams): Promise> { + const { data } = await client.get(`${API.INVOICES}/${params.id}/send/preview`, { params }) + return data + }, + + async clone(id: number): Promise> { + const { data } = await client.post(`${API.INVOICES}/${id}/clone`) + return data + }, + + async changeStatus(payload: InvoiceStatusPayload): Promise> { + const { data } = await client.post(`${API.INVOICES}/${payload.id}/status`, payload) + return data + }, + + async getNextNumber(params?: { key?: string }): Promise { + const { data } = await client.get(API.NEXT_NUMBER, { params: { key: 'invoice', ...params } }) + return data + }, + + async getTemplates(): Promise { + const { data } = await client.get(API.INVOICE_TEMPLATES) + return data + }, +} diff --git a/resources/scripts-v2/api/services/item.service.ts b/resources/scripts-v2/api/services/item.service.ts new file mode 100644 index 00000000..a2fdbb34 --- /dev/null +++ b/resources/scripts-v2/api/services/item.service.ts @@ -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 +} + +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 { + const { data } = await client.get(API.ITEMS, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.ITEMS}/${id}`) + return data + }, + + async create(payload: CreateItemPayload): Promise> { + const { data } = await client.post(API.ITEMS, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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> { + const { data } = await client.get(API.UNITS, { params }) + return data + }, + + async getUnit(id: number): Promise> { + const { data } = await client.get(`${API.UNITS}/${id}`) + return data + }, + + async createUnit(payload: CreateUnitPayload): Promise> { + const { data } = await client.post(API.UNITS, payload) + return data + }, + + async updateUnit(id: number, payload: CreateUnitPayload): Promise> { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/mail.service.ts b/resources/scripts-v2/api/services/mail.service.ts new file mode 100644 index 00000000..3145bbe5 --- /dev/null +++ b/resources/scripts-v2/api/services/mail.service.ts @@ -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 { + const { data } = await client.get(API.MAIL_DRIVERS) + return data + }, + + async getConfig(): Promise { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/member.service.ts b/resources/scripts-v2/api/services/member.service.ts new file mode 100644 index 00000000..228ce6c7 --- /dev/null +++ b/resources/scripts-v2/api/services/member.service.ts @@ -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 { + const { data } = await client.get(API.MEMBERS, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.MEMBERS}/${id}`) + return data + }, + + async create(payload: UpdateMemberPayload): Promise> { + const { data } = await client.post(API.MEMBERS, payload) + return data + }, + + async update(id: number, payload: UpdateMemberPayload): Promise> { + 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 { + const { data } = await client.get(API.COMPANY_INVITATIONS) + return data + }, + + async invite(payload: InviteMemberPayload): Promise> { + 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 { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/module.service.ts b/resources/scripts-v2/api/services/module.service.ts new file mode 100644 index 00000000..be2c0f6a --- /dev/null +++ b/resources/scripts-v2/api/services/module.service.ts @@ -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> { + const { data } = await client.get(API.MODULES) + return data + }, + + async get(module: string): Promise { + const { data } = await client.get(`${API.MODULES}/${module}`) + return data + }, + + async checkToken(apiToken: string): Promise { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/note.service.ts b/resources/scripts-v2/api/services/note.service.ts new file mode 100644 index 00000000..7858d180 --- /dev/null +++ b/resources/scripts-v2/api/services/note.service.ts @@ -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> { + const { data } = await client.get(API.NOTES, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.NOTES}/${id}`) + return data + }, + + async create(payload: CreateNotePayload): Promise { + const { data } = await client.post(API.NOTES, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/payment.service.ts b/resources/scripts-v2/api/services/payment.service.ts new file mode 100644 index 00000000..8e413545 --- /dev/null +++ b/resources/scripts-v2/api/services/payment.service.ts @@ -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 { + const { data } = await client.get(API.PAYMENTS, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.PAYMENTS}/${id}`) + return data + }, + + async create(payload: CreatePaymentPayload): Promise> { + const { data } = await client.post(API.PAYMENTS, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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> { + const { data } = await client.post(`${API.PAYMENTS}/${payload.id}/send`, payload) + return data + }, + + async sendPreview(id: number, params?: Record): Promise> { + const { data } = await client.get(`${API.PAYMENTS}/${id}/send/preview`, { params }) + return data + }, + + async getNextNumber(params?: { key?: string }): Promise { + const { data } = await client.get(API.NEXT_NUMBER, { params: { key: 'payment', ...params } }) + return data + }, + + // Payment Methods + async listMethods(params?: ListParams): Promise> { + const { data } = await client.get(API.PAYMENT_METHODS, { params }) + return data + }, + + async getMethod(id: number): Promise> { + const { data } = await client.get(`${API.PAYMENT_METHODS}/${id}`) + return data + }, + + async createMethod(payload: CreatePaymentMethodPayload): Promise> { + const { data } = await client.post(API.PAYMENT_METHODS, payload) + return data + }, + + async updateMethod(id: number, payload: CreatePaymentMethodPayload): Promise> { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/pdf.service.ts b/resources/scripts-v2/api/services/pdf.service.ts new file mode 100644 index 00000000..480942b3 --- /dev/null +++ b/resources/scripts-v2/api/services/pdf.service.ts @@ -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 { + const { data } = await client.get(API.PDF_DRIVERS) + return data + }, + + async getConfig(): Promise { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/recurring-invoice.service.ts b/resources/scripts-v2/api/services/recurring-invoice.service.ts new file mode 100644 index 00000000..e5e0699a --- /dev/null +++ b/resources/scripts-v2/api/services/recurring-invoice.service.ts @@ -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 { + const { data } = await client.get(API.RECURRING_INVOICES, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.RECURRING_INVOICES}/${id}`) + return data + }, + + async create(payload: CreateRecurringInvoicePayload): Promise> { + const { data } = await client.post(API.RECURRING_INVOICES, payload) + return data + }, + + async update( + id: number, + payload: Partial, + ): Promise> { + 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 { + const { data } = await client.get(API.RECURRING_INVOICE_FREQUENCY, { params }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/report.service.ts b/resources/scripts-v2/api/services/report.service.ts new file mode 100644 index 00000000..49590023 --- /dev/null +++ b/resources/scripts-v2/api/services/report.service.ts @@ -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 { + const { data } = await client.get(API.DASHBOARD, { + params: { ...params, report_type: 'sales' }, + }) + return data + }, + + async getProfitLossReport(params: ReportParams): Promise { + const { data } = await client.get(API.DASHBOARD, { + params: { ...params, report_type: 'profit_loss' }, + }) + return data + }, + + async getExpenseReport(params: ReportParams): Promise { + const { data } = await client.get(API.DASHBOARD, { + params: { ...params, report_type: 'expenses' }, + }) + return data + }, + + async getTaxReport(params: ReportParams): Promise { + const { data } = await client.get(API.DASHBOARD, { + params: { ...params, report_type: 'tax' }, + }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/role.service.ts b/resources/scripts-v2/api/services/role.service.ts new file mode 100644 index 00000000..3604c6f9 --- /dev/null +++ b/resources/scripts-v2/api/services/role.service.ts @@ -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> { + const { data } = await client.get(API.ROLES, { params }) + return data + }, + + async get(id: number): Promise> { + 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): Promise> { + 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 { + const { data } = await client.get(API.ABILITIES, { params }) + return data + }, +} diff --git a/resources/scripts-v2/api/services/setting.service.ts b/resources/scripts-v2/api/services/setting.service.ts new file mode 100644 index 00000000..a1149b75 --- /dev/null +++ b/resources/scripts-v2/api/services/setting.service.ts @@ -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 +} + +export interface NumberPlaceholdersParams { + key: string +} + +export interface NumberPlaceholder { + description: string + value: string +} + +export const settingService = { + // Global Settings (admin-level) + async getGlobalSettings(): Promise> { + 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): Promise { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/tax-type.service.ts b/resources/scripts-v2/api/services/tax-type.service.ts new file mode 100644 index 00000000..b072e5cf --- /dev/null +++ b/resources/scripts-v2/api/services/tax-type.service.ts @@ -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> { + const { data } = await client.get(API.TAX_TYPES, { params }) + return data + }, + + async get(id: number): Promise> { + const { data } = await client.get(`${API.TAX_TYPES}/${id}`) + return data + }, + + async create(payload: CreateTaxTypePayload): Promise> { + const { data } = await client.post(API.TAX_TYPES, payload) + return data + }, + + async update(id: number, payload: Partial): Promise> { + 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 + }, +} diff --git a/resources/scripts-v2/api/services/user.service.ts b/resources/scripts-v2/api/services/user.service.ts new file mode 100644 index 00000000..49e5ec75 --- /dev/null +++ b/resources/scripts-v2/api/services/user.service.ts @@ -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 +} + +export interface UserSettingsResponse { + [key: string]: string | null +} + +export const userService = { + async getProfile(): Promise> { + const { data } = await client.get(API.ME) + return data + }, + + async updateProfile(payload: UpdateProfilePayload): Promise> { + const { data } = await client.put(API.ME, payload) + return data + }, + + async getSettings(settings?: string[]): Promise { + 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> { + const { data } = await client.post(API.ME_UPLOAD_AVATAR, payload) + return data + }, +} diff --git a/resources/scripts-v2/composables/index.ts b/resources/scripts-v2/composables/index.ts new file mode 100644 index 00000000..7cbcf2f9 --- /dev/null +++ b/resources/scripts-v2/composables/index.ts @@ -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' diff --git a/resources/scripts-v2/composables/use-api.ts b/resources/scripts-v2/composables/use-api.ts new file mode 100644 index 00000000..7507b0f9 --- /dev/null +++ b/resources/scripts-v2/composables/use-api.ts @@ -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 { + data: Ref + loading: Ref + error: Ref + execute: (...args: unknown[]) => Promise + 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( + apiFn: (...args: never[]) => Promise +): UseApiReturn { + const data = ref(null) as Ref + const loading = ref(false) + const error = ref(null) as Ref + + async function execute(...args: unknown[]): Promise { + loading.value = true + error.value = null + + try { + const result = await (apiFn as (...a: unknown[]) => Promise)(...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 } +} diff --git a/resources/scripts-v2/composables/use-auth.ts b/resources/scripts-v2/composables/use-auth.ts new file mode 100644 index 00000000..d108fc5f --- /dev/null +++ b/resources/scripts-v2/composables/use-auth.ts @@ -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 + isAuthenticated: ComputedRef + isOwner: ComputedRef + isSuperAdmin: ComputedRef + setUser: (user: User) => void + clearUser: () => void + login: (loginFn: () => Promise) => Promise + logout: (logoutFn: () => Promise) => Promise +} + +const currentUser = ref(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(() => currentUser.value !== null) + + const isOwner = computed( + () => currentUser.value?.is_owner === true + ) + + const isSuperAdmin = computed( + () => 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): Promise { + 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): Promise { + 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, + } +} diff --git a/resources/scripts-v2/composables/use-company.ts b/resources/scripts-v2/composables/use-company.ts new file mode 100644 index 00000000..6aa98150 --- /dev/null +++ b/resources/scripts-v2/composables/use-company.ts @@ -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 + companies: Ref + selectedCompanySettings: Ref + selectedCompanyCurrency: Ref + isAdminMode: Ref + hasCompany: ComputedRef + 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(null) +const companies = ref([]) +const selectedCompanySettings = ref({}) +const selectedCompanyCurrency = ref(null) +const isAdminMode = ref( + ls.get(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( + () => 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, + } +} diff --git a/resources/scripts-v2/composables/use-currency.ts b/resources/scripts-v2/composables/use-currency.ts new file mode 100644 index 00000000..c49e5563 --- /dev/null +++ b/resources/scripts-v2/composables/use-currency.ts @@ -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 + 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([]) + +/** + * 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, + } +} diff --git a/resources/scripts-v2/composables/use-date.ts b/resources/scripts-v2/composables/use-date.ts new file mode 100644 index 00000000..5b7a4cb3 --- /dev/null +++ b/resources/scripts-v2/composables/use-date.ts @@ -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, + } +} diff --git a/resources/scripts-v2/composables/use-dialog.ts b/resources/scripts-v2/composables/use-dialog.ts new file mode 100644 index 00000000..b8a8bcd0 --- /dev/null +++ b/resources/scripts-v2/composables/use-dialog.ts @@ -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> + openConfirm: (options: OpenConfirmOptions) => Promise + 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({ ...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 { + 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((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, + } +} diff --git a/resources/scripts-v2/composables/use-filters.ts b/resources/scripts-v2/composables/use-filters.ts new file mode 100644 index 00000000..54a3530a --- /dev/null +++ b/resources/scripts-v2/composables/use-filters.ts @@ -0,0 +1,105 @@ +import { reactive, ref, watch } from 'vue' +import type { Ref } from 'vue' + +export interface UseFiltersOptions> { + initialFilters: T + debounceMs?: number + onChange?: (filters: T) => void +} + +export interface UseFiltersReturn> { + filters: T + activeFilterCount: Ref + setFilter: (key: K, value: T[K]) => void + clearFilters: () => void + clearFilter: (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>( + options: UseFiltersOptions +): UseFiltersReturn { + const { initialFilters, debounceMs = 300, onChange } = options + + const filters = reactive({ ...initialFilters }) as T + + const activeFilterCount = ref(0) + + let debounceTimer: ReturnType | null = null + + function updateActiveFilterCount(): void { + let count = 0 + const initialKeys = Object.keys(initialFilters) as Array + + 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(key: K, value: T[K]): void { + filters[key] = value + debouncedApply() + } + + function clearFilter(key: K): void { + filters[key] = initialFilters[key] + debouncedApply() + } + + function clearFilters(): void { + const keys = Object.keys(initialFilters) as Array + 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, + } +} diff --git a/resources/scripts-v2/composables/use-modal.ts b/resources/scripts-v2/composables/use-modal.ts new file mode 100644 index 00000000..ae9847f9 --- /dev/null +++ b/resources/scripts-v2/composables/use-modal.ts @@ -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> + isEdit: ComputedRef + 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({ ...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(() => 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, + } +} diff --git a/resources/scripts-v2/composables/use-notification.ts b/resources/scripts-v2/composables/use-notification.ts new file mode 100644 index 00000000..5a9236ec --- /dev/null +++ b/resources/scripts-v2/composables/use-notification.ts @@ -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> + 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([]) + +/** + * 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, + } +} diff --git a/resources/scripts-v2/composables/use-pagination.ts b/resources/scripts-v2/composables/use-pagination.ts new file mode 100644 index 00000000..d75fa8be --- /dev/null +++ b/resources/scripts-v2/composables/use-pagination.ts @@ -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 + limit: Ref + totalCount: Ref + totalPages: ComputedRef + hasNextPage: ComputedRef + hasPrevPage: ComputedRef + 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(initialPage) + const limit = ref(initialLimit) + const totalCount = ref(0) + + const totalPages = computed(() => { + if (totalCount.value === 0 || limit.value === 0) { + return 0 + } + return Math.ceil(totalCount.value / limit.value) + }) + + const hasNextPage = computed(() => page.value < totalPages.value) + + const hasPrevPage = computed(() => 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, + } +} diff --git a/resources/scripts-v2/composables/use-permissions.ts b/resources/scripts-v2/composables/use-permissions.ts new file mode 100644 index 00000000..6caa14c2 --- /dev/null +++ b/resources/scripts-v2/composables/use-permissions.ts @@ -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 + isOwner: Ref + isSuperAdmin: Ref + setAbilities: (abilities: UserAbility[]) => void + setOwner: (owner: boolean) => void + setSuperAdmin: (superAdmin: boolean) => void + hasAbility: (ability: Ability | Ability[]) => boolean + hasAllAbilities: (abilities: Ability[]) => boolean + canViewCustomer: ComputedRef + canCreateCustomer: ComputedRef + canEditCustomer: ComputedRef + canDeleteCustomer: ComputedRef + canViewInvoice: ComputedRef + canCreateInvoice: ComputedRef + canEditInvoice: ComputedRef + canDeleteInvoice: ComputedRef + canViewEstimate: ComputedRef + canCreateEstimate: ComputedRef + canEditEstimate: ComputedRef + canDeleteEstimate: ComputedRef + canViewPayment: ComputedRef + canCreatePayment: ComputedRef + canEditPayment: ComputedRef + canDeletePayment: ComputedRef + canViewExpense: ComputedRef + canCreateExpense: ComputedRef + canEditExpense: ComputedRef + canDeleteExpense: ComputedRef + canViewDashboard: ComputedRef + canViewFinancialReport: ComputedRef +} + +const currentAbilities = ref([]) +const isOwner = ref(false) +const isSuperAdmin = ref(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(() => hasAbility(ABILITIES.VIEW_CUSTOMER)) + const canCreateCustomer = computed(() => hasAbility(ABILITIES.CREATE_CUSTOMER)) + const canEditCustomer = computed(() => hasAbility(ABILITIES.EDIT_CUSTOMER)) + const canDeleteCustomer = computed(() => hasAbility(ABILITIES.DELETE_CUSTOMER)) + + const canViewInvoice = computed(() => hasAbility(ABILITIES.VIEW_INVOICE)) + const canCreateInvoice = computed(() => hasAbility(ABILITIES.CREATE_INVOICE)) + const canEditInvoice = computed(() => hasAbility(ABILITIES.EDIT_INVOICE)) + const canDeleteInvoice = computed(() => hasAbility(ABILITIES.DELETE_INVOICE)) + + const canViewEstimate = computed(() => hasAbility(ABILITIES.VIEW_ESTIMATE)) + const canCreateEstimate = computed(() => hasAbility(ABILITIES.CREATE_ESTIMATE)) + const canEditEstimate = computed(() => hasAbility(ABILITIES.EDIT_ESTIMATE)) + const canDeleteEstimate = computed(() => hasAbility(ABILITIES.DELETE_ESTIMATE)) + + const canViewPayment = computed(() => hasAbility(ABILITIES.VIEW_PAYMENT)) + const canCreatePayment = computed(() => hasAbility(ABILITIES.CREATE_PAYMENT)) + const canEditPayment = computed(() => hasAbility(ABILITIES.EDIT_PAYMENT)) + const canDeletePayment = computed(() => hasAbility(ABILITIES.DELETE_PAYMENT)) + + const canViewExpense = computed(() => hasAbility(ABILITIES.VIEW_EXPENSE)) + const canCreateExpense = computed(() => hasAbility(ABILITIES.CREATE_EXPENSE)) + const canEditExpense = computed(() => hasAbility(ABILITIES.EDIT_EXPENSE)) + const canDeleteExpense = computed(() => hasAbility(ABILITIES.DELETE_EXPENSE)) + + const canViewDashboard = computed(() => hasAbility(ABILITIES.DASHBOARD)) + const canViewFinancialReport = computed(() => 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, + } +} diff --git a/resources/scripts-v2/composables/use-sidebar.ts b/resources/scripts-v2/composables/use-sidebar.ts new file mode 100644 index 00000000..375d1349 --- /dev/null +++ b/resources/scripts-v2/composables/use-sidebar.ts @@ -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 + isSidebarOpen: Ref + toggleCollapse: () => void + setSidebarVisibility: (visible: boolean) => void +} + +const isCollapsed = ref( + ls.get(LS_KEYS.SIDEBAR_COLLAPSED) === 'true' +) + +const isSidebarOpen = ref(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, + } +} diff --git a/resources/scripts-v2/composables/use-theme.ts b/resources/scripts-v2/composables/use-theme.ts new file mode 100644 index 00000000..7d303d09 --- /dev/null +++ b/resources/scripts-v2/composables/use-theme.ts @@ -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 + setTheme: (theme: Theme) => void + applyTheme: (theme?: Theme) => void +} + +const currentTheme = ref( + (ls.get(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, + } +} diff --git a/resources/scripts-v2/config/abilities.ts b/resources/scripts-v2/config/abilities.ts new file mode 100644 index 00000000..602a2f62 --- /dev/null +++ b/resources/scripts-v2/config/abilities.ts @@ -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] diff --git a/resources/scripts-v2/config/constants.ts b/resources/scripts-v2/config/constants.ts new file mode 100644 index 00000000..f1a4c123 --- /dev/null +++ b/resources/scripts-v2/config/constants.ts @@ -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 = { + 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 diff --git a/resources/scripts-v2/config/index.ts b/resources/scripts-v2/config/index.ts new file mode 100644 index 00000000..d210b938 --- /dev/null +++ b/resources/scripts-v2/config/index.ts @@ -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' diff --git a/resources/scripts-v2/types/api.ts b/resources/scripts-v2/types/api.ts new file mode 100644 index 00000000..5ee4ab46 --- /dev/null +++ b/resources/scripts-v2/types/api.ts @@ -0,0 +1,41 @@ +export interface ApiResponse { + data: T +} + +export interface PaginatedResponse { + 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 +} + +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[] +} diff --git a/resources/scripts-v2/types/domain/company.ts b/resources/scripts-v2/types/domain/company.ts new file mode 100644 index 00000000..5673c874 --- /dev/null +++ b/resources/scripts-v2/types/domain/company.ts @@ -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', +} diff --git a/resources/scripts-v2/types/domain/currency.ts b/resources/scripts-v2/types/domain/currency.ts new file mode 100644 index 00000000..c8282a66 --- /dev/null +++ b/resources/scripts-v2/types/domain/currency.ts @@ -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 + company_id: number + active: boolean + company?: import('./company').Company +} diff --git a/resources/scripts-v2/types/domain/custom-field.ts b/resources/scripts-v2/types/domain/custom-field.ts new file mode 100644 index 00000000..c174740e --- /dev/null +++ b/resources/scripts-v2/types/domain/custom-field.ts @@ -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 +} diff --git a/resources/scripts-v2/types/domain/customer.ts b/resources/scripts-v2/types/domain/customer.ts new file mode 100644 index 00000000..907164ee --- /dev/null +++ b/resources/scripts-v2/types/domain/customer.ts @@ -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
+ shipping?: Partial
+ enable_portal?: boolean + customFields?: CustomFieldValue[] + fields?: CustomFieldValue[] +} diff --git a/resources/scripts-v2/types/domain/estimate.ts b/resources/scripts-v2/types/domain/estimate.ts new file mode 100644 index 00000000..b66d9a7b --- /dev/null +++ b/resources/scripts-v2/types/domain/estimate.ts @@ -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[] + 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[] + unit_name?: string | null +} diff --git a/resources/scripts-v2/types/domain/expense.ts b/resources/scripts-v2/types/domain/expense.ts new file mode 100644 index 00000000..1f8b6034 --- /dev/null +++ b/resources/scripts-v2/types/domain/expense.ts @@ -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[] +} diff --git a/resources/scripts-v2/types/domain/index.ts b/resources/scripts-v2/types/domain/index.ts new file mode 100644 index 00000000..50b12fad --- /dev/null +++ b/resources/scripts-v2/types/domain/index.ts @@ -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' diff --git a/resources/scripts-v2/types/domain/invoice.ts b/resources/scripts-v2/types/domain/invoice.ts new file mode 100644 index 00000000..b720ee5d --- /dev/null +++ b/resources/scripts-v2/types/domain/invoice.ts @@ -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[] + 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[] + unit_name?: string | null +} diff --git a/resources/scripts-v2/types/domain/item.ts b/resources/scripts-v2/types/domain/item.ts new file mode 100644 index 00000000..3bd3e6c3 --- /dev/null +++ b/resources/scripts-v2/types/domain/item.ts @@ -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 +} diff --git a/resources/scripts-v2/types/domain/module.ts b/resources/scripts-v2/types/domain/module.ts new file mode 100644 index 00000000..e2c2e2ea --- /dev/null +++ b/resources/scripts-v2/types/domain/module.ts @@ -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 +} diff --git a/resources/scripts-v2/types/domain/note.ts b/resources/scripts-v2/types/domain/note.ts new file mode 100644 index 00000000..9228bf0f --- /dev/null +++ b/resources/scripts-v2/types/domain/note.ts @@ -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 +} diff --git a/resources/scripts-v2/types/domain/payment.ts b/resources/scripts-v2/types/domain/payment.ts new file mode 100644 index 00000000..da4cd865 --- /dev/null +++ b/resources/scripts-v2/types/domain/payment.ts @@ -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[] +} diff --git a/resources/scripts-v2/types/domain/recurring-invoice.ts b/resources/scripts-v2/types/domain/recurring-invoice.ts new file mode 100644 index 00000000..b3d17dc4 --- /dev/null +++ b/resources/scripts-v2/types/domain/recurring-invoice.ts @@ -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[] + taxes?: Partial[] + customFields?: CustomFieldValue[] + fields?: CustomFieldValue[] +} diff --git a/resources/scripts-v2/types/domain/role.ts b/resources/scripts-v2/types/domain/role.ts new file mode 100644 index 00000000..bb3972ff --- /dev/null +++ b/resources/scripts-v2/types/domain/role.ts @@ -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[] +} diff --git a/resources/scripts-v2/types/domain/setting.ts b/resources/scripts-v2/types/domain/setting.ts new file mode 100644 index 00000000..5fddd4fa --- /dev/null +++ b/resources/scripts-v2/types/domain/setting.ts @@ -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 +} diff --git a/resources/scripts-v2/types/domain/tax.ts b/resources/scripts-v2/types/domain/tax.ts new file mode 100644 index 00000000..8624574c --- /dev/null +++ b/resources/scripts-v2/types/domain/tax.ts @@ -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 +} diff --git a/resources/scripts-v2/types/domain/user.ts b/resources/scripts-v2/types/domain/user.ts new file mode 100644 index 00000000..364e1abb --- /dev/null +++ b/resources/scripts-v2/types/domain/user.ts @@ -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 +} diff --git a/resources/scripts-v2/types/env.d.ts b/resources/scripts-v2/types/env.d.ts new file mode 100644 index 00000000..28ae7076 --- /dev/null +++ b/resources/scripts-v2/types/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_TITLE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/resources/scripts-v2/types/index.ts b/resources/scripts-v2/types/index.ts new file mode 100644 index 00000000..f9f1e736 --- /dev/null +++ b/resources/scripts-v2/types/index.ts @@ -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' diff --git a/resources/scripts-v2/types/vue-shims.d.ts b/resources/scripts-v2/types/vue-shims.d.ts new file mode 100644 index 00000000..a69ae5c8 --- /dev/null +++ b/resources/scripts-v2/types/vue-shims.d.ts @@ -0,0 +1,9 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent< + Record, + Record, + unknown + > + export default component +} diff --git a/resources/scripts-v2/utils/error-handling.ts b/resources/scripts-v2/utils/error-handling.ts new file mode 100644 index 00000000..7fa6066e --- /dev/null +++ b/resources/scripts-v2/utils/error-handling.ts @@ -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 + } + } + message?: string +} + +/** + * Normalized API error result. + */ +export interface NormalizedApiError { + message: string + statusCode: number | null + validationErrors: Record + isUnauthorized: boolean + isValidationError: boolean + isNetworkError: boolean +} + +/** + * Known error message to translation key map. + */ +const ERROR_TRANSLATION_MAP: Record = { + '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 { + 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 +} diff --git a/resources/scripts-v2/utils/format-date.ts b/resources/scripts-v2/utils/format-date.ts new file mode 100644 index 00000000..f38abf1d --- /dev/null +++ b/resources/scripts-v2/utils/format-date.ts @@ -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 +} diff --git a/resources/scripts-v2/utils/format-money.ts b/resources/scripts-v2/utils/format-money.ts new file mode 100644 index 00000000..915bd004 --- /dev/null +++ b/resources/scripts-v2/utils/format-money.ts @@ -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) +} diff --git a/resources/scripts-v2/utils/index.ts b/resources/scripts-v2/utils/index.ts new file mode 100644 index 00000000..3ec34f81 --- /dev/null +++ b/resources/scripts-v2/utils/index.ts @@ -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' diff --git a/resources/scripts-v2/utils/local-storage.ts b/resources/scripts-v2/utils/local-storage.ts new file mode 100644 index 00000000..35d3795c --- /dev/null +++ b/resources/scripts-v2/utils/local-storage.ts @@ -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(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(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() +} diff --git a/yarn.lock b/yarn.lock index f0418b37..58d8c5e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -539,6 +539,11 @@ resolved "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz" integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== +"@types/lodash@^4.17.24": + version "4.17.24" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz" + integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ== + "@types/markdown-it@^14.0.0": version "14.1.2" resolved "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz" @@ -576,6 +581,27 @@ dependencies: "@rolldown/pluginutils" "1.0.0-rc.2" +"@volar/language-core@2.4.28": + version "2.4.28" + resolved "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz" + integrity sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ== + dependencies: + "@volar/source-map" "2.4.28" + +"@volar/source-map@2.4.28": + version "2.4.28" + resolved "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz" + integrity sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ== + +"@volar/typescript@2.4.28": + version "2.4.28" + resolved "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz" + integrity sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw== + dependencies: + "@volar/language-core" "2.4.28" + path-browserify "^1.0.1" + vscode-uri "^3.0.8" + "@vue-macros/common@^3.1.1": version "3.1.2" resolved "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz" @@ -598,7 +624,7 @@ estree-walker "^2.0.2" source-map-js "^1.2.1" -"@vue/compiler-dom@3.5.31": +"@vue/compiler-dom@^3.5.0", "@vue/compiler-dom@3.5.31": version "3.5.31" resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz" integrity sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw== @@ -683,6 +709,19 @@ resolved "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz" integrity sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ== +"@vue/language-core@3.2.6": + version "3.2.6" + resolved "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz" + integrity sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg== + dependencies: + "@volar/language-core" "2.4.28" + "@vue/compiler-dom" "^3.5.0" + "@vue/shared" "^3.5.0" + alien-signals "^3.0.0" + muggle-string "^0.4.1" + path-browserify "^1.0.1" + picomatch "^4.0.2" + "@vue/reactivity@3.5.31": version "3.5.31" resolved "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz" @@ -716,7 +755,7 @@ "@vue/compiler-ssr" "3.5.31" "@vue/shared" "3.5.31" -"@vue/shared@3.5.31": +"@vue/shared@^3.5.0", "@vue/shared@3.5.31": version "3.5.31" resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz" integrity sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw== @@ -782,6 +821,11 @@ ajv@^6.14.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +alien-signals@^3.0.0: + version "3.1.2" + resolved "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz" + integrity sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" @@ -914,6 +958,11 @@ csstype@^3.2.3: resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== + debug@^4.3.1, debug@^4.3.2, debug@^4.4.0: version "4.4.3" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" @@ -1551,6 +1600,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" @@ -1594,7 +1648,7 @@ picomatch@^2.3.1: resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -"picomatch@^3 || ^4", picomatch@^4.0.3, picomatch@^4.0.4: +"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: version "4.0.4" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== @@ -1971,6 +2025,11 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +typescript@*, typescript@^6.0.2, typescript@>=4.5.0, typescript@>=5.0.0: + version "6.0.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz" + integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ== + uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz" @@ -2056,6 +2115,11 @@ vite-plugin-full-reload@^1.1.0: optionalDependencies: fsevents "~2.3.3" +vscode-uri@^3.0.8: + version "3.1.0" + resolved "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== + vue-demi@^0.13.11: version "0.13.11" resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz" @@ -2118,6 +2182,14 @@ vue-router@^5.0.0: unplugin-utils "^0.3.1" yaml "^2.8.2" +vue-tsc@^3.2.6: + version "3.2.6" + resolved "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz" + integrity sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q== + dependencies: + "@volar/typescript" "2.4.28" + "@vue/language-core" "3.2.6" + "vue@^2.0.0 || >=3.0.0", "vue@^2.7.0 || ^3.0.0", "vue@^2.7.0 || ^3.2.25", vue@^3.0.0, "vue@^3.0.0-0 || ^2.6.0", vue@^3.0.1, vue@^3.0.11, vue@^3.2.0, vue@^3.2.25, vue@^3.5, vue@^3.5.0, vue@^3.5.11, "vue@>= 3", "vue@>= 3.2.0", vue@3.5.31: version "3.5.31" resolved "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz"