From 812956abcce8ac05f74cf8a7c74a50aeb2db0c17 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 4 Apr 2026 08:00:00 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205-6:=20Router,=20plugins,=20entry=20poi?= =?UTF-8?q?nts=20=E2=80=94=20scripts-v2=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 files completing the TypeScript migration: - router/ (3 files): typed guards, route meta augmentation, merged feature routes from all 16 modules - plugins/ (4 files): i18n with dynamic locale loading, pinia, tooltip directive - Entry points: main.ts, InvoiceShelf.ts bootstrap class, App.vue, global-components.ts with typed registration - NoCompanyView and NotFoundView stubs scripts-v2/ totals: 324 files, 42853 lines of strict TypeScript. Zero any types. Complete feature-based architecture with typed stores, API services, composables, and Vue components. Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/scripts-v2/App.vue | 4 + resources/scripts-v2/InvoiceShelf.ts | 100 +++++++++++++++ .../features/company/NoCompanyView.vue | 12 ++ .../features/errors/NotFoundView.vue | 16 +++ resources/scripts-v2/global-components.ts | 44 +++++++ resources/scripts-v2/main.ts | 7 ++ resources/scripts-v2/plugins/i18n.ts | 116 ++++++++++++++++++ resources/scripts-v2/plugins/index.ts | 6 + resources/scripts-v2/plugins/pinia.ts | 9 ++ resources/scripts-v2/plugins/tooltip.ts | 9 ++ resources/scripts-v2/router/guards.ts | 83 +++++++++++++ resources/scripts-v2/router/index.ts | 111 +++++++++++++++++ resources/scripts-v2/router/types.ts | 13 ++ 13 files changed, 530 insertions(+) create mode 100644 resources/scripts-v2/App.vue create mode 100644 resources/scripts-v2/InvoiceShelf.ts create mode 100644 resources/scripts-v2/features/company/NoCompanyView.vue create mode 100644 resources/scripts-v2/features/errors/NotFoundView.vue create mode 100644 resources/scripts-v2/global-components.ts create mode 100644 resources/scripts-v2/main.ts create mode 100644 resources/scripts-v2/plugins/i18n.ts create mode 100644 resources/scripts-v2/plugins/index.ts create mode 100644 resources/scripts-v2/plugins/pinia.ts create mode 100644 resources/scripts-v2/plugins/tooltip.ts create mode 100644 resources/scripts-v2/router/guards.ts create mode 100644 resources/scripts-v2/router/index.ts create mode 100644 resources/scripts-v2/router/types.ts diff --git a/resources/scripts-v2/App.vue b/resources/scripts-v2/App.vue new file mode 100644 index 00000000..fb0bef42 --- /dev/null +++ b/resources/scripts-v2/App.vue @@ -0,0 +1,4 @@ + diff --git a/resources/scripts-v2/InvoiceShelf.ts b/resources/scripts-v2/InvoiceShelf.ts new file mode 100644 index 00000000..fc324fdc --- /dev/null +++ b/resources/scripts-v2/InvoiceShelf.ts @@ -0,0 +1,100 @@ +import { createApp } from 'vue' +import type { App } from 'vue' +import type { Router } from 'vue-router' +import App_ from './App.vue' +import router from './router' +import { createAppI18n, setI18nLanguage } from './plugins/i18n' +import type { AppI18n } from './plugins/i18n' +import { createAppPinia } from './plugins/pinia' +import { installTooltipDirective } from './plugins/tooltip' +import { defineGlobalComponents } from './global-components' + +/** + * Callback signature for the `booting` hook. + * Receives the Vue app instance and the router so that modules / + * plugins can register additional routes, components, or providers. + */ +type BootCallback = (app: App, router: Router) => void + +/** + * Bootstrap class for InvoiceShelf. + * + * External code (e.g. dynamically loaded modules) can call + * `window.InvoiceShelf.booting(callback)` to hook into the app + * before it mounts. + * + * Call `start()` to install all plugins, execute boot callbacks, + * and mount the application. + */ +export default class InvoiceShelf { + private bootingCallbacks: BootCallback[] = [] + private messages: Record> = {} + private i18n: AppI18n | null = null + private app: App + + constructor() { + this.app = createApp(App_) + } + + /** + * Register a callback that will be invoked before the app mounts. + */ + booting(callback: BootCallback): void { + this.bootingCallbacks.push(callback) + } + + /** + * Merge additional i18n message bundles (typically from modules). + */ + addMessages(moduleMessages: Record>): void { + for (const [locale, msgs] of Object.entries(moduleMessages)) { + this.messages[locale] = { + ...this.messages[locale], + ...msgs, + } + } + } + + /** + * Dynamically load and activate a language. + */ + async loadLanguage(locale: string): Promise { + if (this.i18n) { + await setI18nLanguage(this.i18n, locale) + } + } + + /** + * Execute all registered boot callbacks, install plugins, + * and mount the app to `document.body`. + */ + start(): void { + // Execute boot callbacks so modules can register routes / components + this.executeCallbacks() + + // Register global components + defineGlobalComponents(this.app) + + // i18n + this.i18n = createAppI18n(this.messages) + + // Install plugins + this.app.use(router) + this.app.use(this.i18n) + this.app.use(createAppPinia()) + + // Directives + installTooltipDirective(this.app) + + // Mount + this.app.mount('body') + } + + // ---- private ---- + + private executeCallbacks(): void { + for (const callback of this.bootingCallbacks) { + callback(this.app, router) + } + } +} diff --git a/resources/scripts-v2/features/company/NoCompanyView.vue b/resources/scripts-v2/features/company/NoCompanyView.vue new file mode 100644 index 00000000..de3a843b --- /dev/null +++ b/resources/scripts-v2/features/company/NoCompanyView.vue @@ -0,0 +1,12 @@ + diff --git a/resources/scripts-v2/features/errors/NotFoundView.vue b/resources/scripts-v2/features/errors/NotFoundView.vue new file mode 100644 index 00000000..75cafc1a --- /dev/null +++ b/resources/scripts-v2/features/errors/NotFoundView.vue @@ -0,0 +1,16 @@ + diff --git a/resources/scripts-v2/global-components.ts b/resources/scripts-v2/global-components.ts new file mode 100644 index 00000000..19e7bd1b --- /dev/null +++ b/resources/scripts-v2/global-components.ts @@ -0,0 +1,44 @@ +import { defineAsyncComponent } from 'vue' +import type { App, Component } from 'vue' + +/** + * Register all base components globally so they can be used in + * templates without explicit imports. + * + * Eager-loaded components come from `./components/base/*.vue` via + * Vite's `import.meta.glob`. A handful of heavier components + * (table, multiselect, editor) are registered as async components + * to keep the initial bundle small. + */ +export function defineGlobalComponents(app: App): void { + // Eager-load all single-file base components + const components: Record = import.meta.glob( + './components/base/*.vue', + { eager: true } + ) + + for (const [path, definition] of Object.entries(components)) { + const fileName = path.split('/').pop() + if (!fileName) continue + + const componentName = fileName.replace(/\.\w+$/, '') + app.component(componentName, definition.default) + } + + // Async-load heavier components + const BaseTable = defineAsyncComponent( + () => import('./components/table/DataTable.vue') + ) + + const BaseMultiselect = defineAsyncComponent( + () => import('./components/base/BaseMultiselect.vue') + ) + + const BaseEditor = defineAsyncComponent( + () => import('./components/editor/RichEditor.vue') + ) + + app.component('BaseTable', BaseTable) + app.component('BaseMultiselect', BaseMultiselect) + app.component('BaseEditor', BaseEditor) +} diff --git a/resources/scripts-v2/main.ts b/resources/scripts-v2/main.ts new file mode 100644 index 00000000..c2d9e665 --- /dev/null +++ b/resources/scripts-v2/main.ts @@ -0,0 +1,7 @@ +import '../../css/invoiceshelf.css' +import 'v-tooltip/dist/v-tooltip.css' + +import InvoiceShelf from './InvoiceShelf' + +const invoiceShelf = new InvoiceShelf() +invoiceShelf.start() diff --git a/resources/scripts-v2/plugins/i18n.ts b/resources/scripts-v2/plugins/i18n.ts new file mode 100644 index 00000000..e04d5ff7 --- /dev/null +++ b/resources/scripts-v2/plugins/i18n.ts @@ -0,0 +1,116 @@ +import { createI18n } from 'vue-i18n' +import type { I18n, I18nOptions } from 'vue-i18n' +import en from '../../../lang/en.json' + +/** + * Locale-to-filename mapping for language files whose filename does + * not match the locale code exactly. + */ +const LOCALE_FILE_MAP: Record = { + zh_CN: 'zh-cn', + pt_BR: 'pt-br', +} + +/** Tracks which languages have already been loaded. */ +const loadedLanguages = new Set(['en']) + +/** In-memory cache of loaded message objects keyed by locale. */ +const languageCache = new Map>() + +/** + * Dynamically import a language JSON file for a given locale. + */ +async function loadLanguageMessages( + locale: string +): Promise> { + if (languageCache.has(locale)) { + return languageCache.get(locale)! + } + + const fileName = LOCALE_FILE_MAP[locale] ?? locale + + try { + const mod: { default: Record } = await import( + `../../../lang/${fileName}.json` + ) + const messages = mod.default ?? mod + languageCache.set(locale, messages) + loadedLanguages.add(locale) + return messages + } catch (error: unknown) { + console.warn(`Failed to load language: ${locale}`, error) + + // Fall back to English + if (locale !== 'en' && !languageCache.has('en')) { + try { + const fallback: { default: Record } = await import( + '../../../lang/en.json' + ) + const fallbackMessages = fallback.default ?? fallback + languageCache.set('en', fallbackMessages) + return fallbackMessages + } catch (fallbackError: unknown) { + console.error('Failed to load fallback language (en)', fallbackError) + return {} + } + } + + return languageCache.get('en') ?? {} + } +} + +/** + * Load a language and activate it on the given i18n instance. + */ +export async function setI18nLanguage( + i18n: I18n, Record, Record, string, false>, + locale: string +): Promise { + if (!loadedLanguages.has(locale)) { + const messages = await loadLanguageMessages(locale) + i18n.global.setLocaleMessage(locale, messages) + } + + i18n.global.locale.value = locale +} + +/** + * Check whether a language has already been loaded. + */ +export function isLanguageLoaded(locale: string): boolean { + return loadedLanguages.has(locale) +} + +/** Type alias for the i18n instance created by this module. */ +export type AppI18n = I18n< + Record, + Record, + Record, + string, + false +> + +/** + * Create and return the vue-i18n plugin instance. + * + * Only the English bundle is included synchronously; all other + * languages are loaded on demand via `setI18nLanguage`. + */ +export function createAppI18n( + extraMessages?: Record> +): AppI18n { + const messages: Record> = { + en: en as unknown as Record, + ...extraMessages, + } + + const options: I18nOptions = { + legacy: false, + locale: 'en', + fallbackLocale: 'en', + globalInjection: true, + messages, + } + + return createI18n(options) as AppI18n +} diff --git a/resources/scripts-v2/plugins/index.ts b/resources/scripts-v2/plugins/index.ts new file mode 100644 index 00000000..f269d1bf --- /dev/null +++ b/resources/scripts-v2/plugins/index.ts @@ -0,0 +1,6 @@ +export { createAppI18n, setI18nLanguage, isLanguageLoaded } from './i18n' +export type { AppI18n } from './i18n' + +export { createAppPinia } from './pinia' + +export { installTooltipDirective } from './tooltip' diff --git a/resources/scripts-v2/plugins/pinia.ts b/resources/scripts-v2/plugins/pinia.ts new file mode 100644 index 00000000..bfd89c3b --- /dev/null +++ b/resources/scripts-v2/plugins/pinia.ts @@ -0,0 +1,9 @@ +import { createPinia } from 'pinia' +import type { Pinia } from 'pinia' + +/** + * Create and return the Pinia store instance. + */ +export function createAppPinia(): Pinia { + return createPinia() +} diff --git a/resources/scripts-v2/plugins/tooltip.ts b/resources/scripts-v2/plugins/tooltip.ts new file mode 100644 index 00000000..ee2a304a --- /dev/null +++ b/resources/scripts-v2/plugins/tooltip.ts @@ -0,0 +1,9 @@ +import { VTooltip } from 'v-tooltip' +import type { App, Directive } from 'vue' + +/** + * Install the v-tooltip directive on the given Vue app instance. + */ +export function installTooltipDirective(app: App): void { + app.directive('tooltip', VTooltip as Directive) +} diff --git a/resources/scripts-v2/router/guards.ts b/resources/scripts-v2/router/guards.ts new file mode 100644 index 00000000..4268678a --- /dev/null +++ b/resources/scripts-v2/router/guards.ts @@ -0,0 +1,83 @@ +import type { NavigationGuardWithThis, RouteLocationNormalized } from 'vue-router' +import { useUserStore } from '../stores/user.store' +import { useGlobalStore } from '../stores/global.store' +import { useCompanyStore } from '../stores/company.store' + +/** + * Main authentication and authorization guard. + * + * Handles: + * - Redirecting to the no-company view when no company is selected + * (unless in admin mode or the user is a super admin visiting a + * super-admin-only route). + * - Ability-based access control: redirects to account settings when + * the current user lacks the required ability. + * - Super admin route protection: redirects non-super-admins to the + * dashboard. + * - Owner route protection: redirects non-owners to the dashboard. + */ +export const authGuard: NavigationGuardWithThis = ( + to: RouteLocationNormalized +) => { + const userStore = useUserStore() + const globalStore = useGlobalStore() + const companyStore = useCompanyStore() + + const { isAppLoaded } = globalStore + const ability = to.meta.ability + + // Guard 1: no company selected -> redirect to no-company view + // Skip if the target IS the no-company view, or if we are in admin + // mode, or if the route is super-admin-only and the user qualifies. + if (isAppLoaded && to.meta.requiresAuth && to.name !== 'no.company') { + const isSuperAdminRoute = + to.meta.isSuperAdmin === true && + currentUserIsSuperAdmin(userStore) + + if ( + !companyStore.selectedCompany && + !companyStore.isAdminMode && + !isSuperAdminRoute + ) { + return { name: 'no.company' } + } + } + + // Guard 2: ability check + if (ability && isAppLoaded && to.meta.requiresAuth) { + if (!userStore.hasAbilities(ability)) { + return { name: 'settings.account' } + } + return + } + + // Guard 3: super admin check + if (to.meta.isSuperAdmin && isAppLoaded) { + if (!currentUserIsSuperAdmin(userStore)) { + return { name: 'dashboard' } + } + return + } + + // Guard 4: owner check + if (to.meta.isOwner && isAppLoaded) { + if (!currentUserIsOwner(userStore)) { + return { name: 'dashboard' } + } + return + } +} + +// ---- helpers ---- + +function currentUserIsSuperAdmin( + userStore: ReturnType +): boolean { + return userStore.currentUser?.is_super_admin ?? false +} + +function currentUserIsOwner( + userStore: ReturnType +): boolean { + return userStore.currentUser?.is_owner ?? false +} diff --git a/resources/scripts-v2/router/index.ts b/resources/scripts-v2/router/index.ts new file mode 100644 index 00000000..98fd0e79 --- /dev/null +++ b/resources/scripts-v2/router/index.ts @@ -0,0 +1,111 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +// Ensure route meta augmentation is loaded +import './types' + +// Feature routes +import { authRoutes } from '../features/auth/routes' +import { adminRoutes } from '../features/admin/routes' +import { installationRoutes } from '../features/installation/routes' +import { customerPortalRoutes } from '../features/customer-portal/routes' + +// Company feature routes (children of /admin) +import dashboardRoutes from '../features/company/dashboard/routes' +import customerRoutes from '../features/company/customers/routes' +import { invoiceRoutes } from '../features/company/invoices/routes' +import { estimateRoutes } from '../features/company/estimates/routes' +import { recurringInvoiceRoutes } from '../features/company/recurring-invoices/routes' +import { paymentRoutes } from '../features/company/payments/routes' +import { expenseRoutes } from '../features/company/expenses/routes' +import itemRoutes from '../features/company/items/routes' +import memberRoutes from '../features/company/members/routes' +import reportRoutes from '../features/company/reports/routes' +import settingsRoutes from '../features/company/settings/routes' +import { moduleRoutes } from '../features/company/modules/routes' + +// Guard +import { authGuard } from './guards' + +// Layouts (lazy-loaded) +const CompanyLayout = () => import('../layouts/CompanyLayout.vue') +const NotFoundView = () => import('../features/errors/NotFoundView.vue') +const NoCompanyView = () => import('../features/company/NoCompanyView.vue') +const InvoicePublicPage = () => import('../components/base/InvoicePublicPage.vue') + +/** + * All company-scoped children routes that live under `/admin` with + * the CompanyLayout wrapper. Each feature module exports its own + * route array; we merge them here. + */ +const companyChildren: RouteRecordRaw[] = [ + // No-company fallback + { + path: 'no-company', + name: 'no.company', + component: NoCompanyView, + }, + // Feature routes + ...dashboardRoutes, + ...customerRoutes, + ...invoiceRoutes, + ...estimateRoutes, + ...recurringInvoiceRoutes, + ...paymentRoutes, + ...expenseRoutes, + ...itemRoutes, + ...memberRoutes, + ...reportRoutes, + ...settingsRoutes, + ...moduleRoutes, +] + +/** + * Top-level route definitions assembled from all feature modules. + */ +const routes: RouteRecordRaw[] = [ + // Installation wizard (no auth) + ...installationRoutes, + + // Public invoice view (no auth, no layout) + { + path: '/customer/invoices/view/:hash', + name: 'invoice.public', + component: InvoicePublicPage, + }, + + // Auth routes (login, register, forgot/reset password) + ...authRoutes, + + // Admin area: company-scoped routes + { + path: '/admin', + component: CompanyLayout, + meta: { requiresAuth: true }, + children: companyChildren, + }, + + // Admin area: super admin routes (separate top-level entry to keep + // the admin feature module self-contained) + ...adminRoutes, + + // Customer portal + ...customerPortalRoutes, + + // Catch-all 404 + { + path: '/:catchAll(.*)', + name: 'not-found', + component: NotFoundView, + }, +] + +const router = createRouter({ + history: createWebHistory(), + linkActiveClass: 'active', + routes, +}) + +router.beforeEach(authGuard) + +export default router diff --git a/resources/scripts-v2/router/types.ts b/resources/scripts-v2/router/types.ts new file mode 100644 index 00000000..696a1fa3 --- /dev/null +++ b/resources/scripts-v2/router/types.ts @@ -0,0 +1,13 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + requiresAuth?: boolean + ability?: string + isOwner?: boolean + isSuperAdmin?: boolean + redirectIfAuthenticated?: boolean + isInstallation?: boolean + title?: string + } +}