Phase 5-6: Router, plugins, entry points — scripts-v2 complete

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) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 08:00:00 +02:00
parent d91f6ff2e3
commit 812956abcc
13 changed files with 530 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
<template>
<router-view />
<BaseDialog />
</template>

View File

@@ -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<string, Record<string, unknown>> = {}
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<string, Record<string, unknown>>): 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<void> {
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)
}
}
}

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex h-full items-center justify-center p-6">
<div class="text-center">
<h2 class="text-xl font-semibold text-gray-700">
{{ $t('general.no_company_selected', 'No company selected') }}
</h2>
<p class="mt-2 text-gray-500">
{{ $t('general.select_company_to_continue', 'Please select a company to continue.') }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-300">404</h1>
<p class="mt-4 text-lg text-gray-500">
{{ $t('errors.page_not_found', 'Page not found') }}
</p>
<router-link
to="/admin/dashboard"
class="mt-6 inline-block text-primary-500 hover:text-primary-600"
>
{{ $t('general.go_to_dashboard', 'Go to Dashboard') }}
</router-link>
</div>
</div>
</template>

View File

@@ -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<string, { default: Component }> = 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)
}

View File

@@ -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()

View File

@@ -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<string, string> = {
zh_CN: 'zh-cn',
pt_BR: 'pt-br',
}
/** Tracks which languages have already been loaded. */
const loadedLanguages = new Set<string>(['en'])
/** In-memory cache of loaded message objects keyed by locale. */
const languageCache = new Map<string, Record<string, unknown>>()
/**
* Dynamically import a language JSON file for a given locale.
*/
async function loadLanguageMessages(
locale: string
): Promise<Record<string, unknown>> {
if (languageCache.has(locale)) {
return languageCache.get(locale)!
}
const fileName = LOCALE_FILE_MAP[locale] ?? locale
try {
const mod: { default: Record<string, unknown> } = 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<string, unknown> } = 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<string, unknown>, Record<string, unknown>, Record<string, unknown>, string, false>,
locale: string
): Promise<void> {
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<string, unknown>,
Record<string, unknown>,
Record<string, unknown>,
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<string, Record<string, unknown>>
): AppI18n {
const messages: Record<string, Record<string, unknown>> = {
en: en as unknown as Record<string, unknown>,
...extraMessages,
}
const options: I18nOptions = {
legacy: false,
locale: 'en',
fallbackLocale: 'en',
globalInjection: true,
messages,
}
return createI18n(options) as AppI18n
}

View File

@@ -0,0 +1,6 @@
export { createAppI18n, setI18nLanguage, isLanguageLoaded } from './i18n'
export type { AppI18n } from './i18n'
export { createAppPinia } from './pinia'
export { installTooltipDirective } from './tooltip'

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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<undefined> = (
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<typeof useUserStore>
): boolean {
return userStore.currentUser?.is_super_admin ?? false
}
function currentUserIsOwner(
userStore: ReturnType<typeof useUserStore>
): boolean {
return userStore.currentUser?.is_owner ?? false
}

View File

@@ -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

View File

@@ -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
}
}