Phase 1: TypeScript foundation in scripts-v2/

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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