mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 01:04:03 +00:00
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:
4
resources/scripts-v2/App.vue
Normal file
4
resources/scripts-v2/App.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<BaseDialog />
|
||||
</template>
|
||||
100
resources/scripts-v2/InvoiceShelf.ts
Normal file
100
resources/scripts-v2/InvoiceShelf.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
resources/scripts-v2/features/company/NoCompanyView.vue
Normal file
12
resources/scripts-v2/features/company/NoCompanyView.vue
Normal 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>
|
||||
16
resources/scripts-v2/features/errors/NotFoundView.vue
Normal file
16
resources/scripts-v2/features/errors/NotFoundView.vue
Normal 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>
|
||||
44
resources/scripts-v2/global-components.ts
Normal file
44
resources/scripts-v2/global-components.ts
Normal 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)
|
||||
}
|
||||
7
resources/scripts-v2/main.ts
Normal file
7
resources/scripts-v2/main.ts
Normal 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()
|
||||
116
resources/scripts-v2/plugins/i18n.ts
Normal file
116
resources/scripts-v2/plugins/i18n.ts
Normal 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
|
||||
}
|
||||
6
resources/scripts-v2/plugins/index.ts
Normal file
6
resources/scripts-v2/plugins/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { createAppI18n, setI18nLanguage, isLanguageLoaded } from './i18n'
|
||||
export type { AppI18n } from './i18n'
|
||||
|
||||
export { createAppPinia } from './pinia'
|
||||
|
||||
export { installTooltipDirective } from './tooltip'
|
||||
9
resources/scripts-v2/plugins/pinia.ts
Normal file
9
resources/scripts-v2/plugins/pinia.ts
Normal 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()
|
||||
}
|
||||
9
resources/scripts-v2/plugins/tooltip.ts
Normal file
9
resources/scripts-v2/plugins/tooltip.ts
Normal 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)
|
||||
}
|
||||
83
resources/scripts-v2/router/guards.ts
Normal file
83
resources/scripts-v2/router/guards.ts
Normal 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
|
||||
}
|
||||
111
resources/scripts-v2/router/index.ts
Normal file
111
resources/scripts-v2/router/index.ts
Normal 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
|
||||
13
resources/scripts-v2/router/types.ts
Normal file
13
resources/scripts-v2/router/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user