2026.06.04 frontend építés közben

This commit is contained in:
Roo
2026-06-04 07:26:22 +00:00
parent 7adf6cc3e3
commit 59a30ac428
3302 changed files with 24091 additions and 1771 deletions

24
old_maps/frontend_old/.gitignore vendored Executable file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -0,0 +1,18 @@
import { _replaceAppConfig } from '#app/config'
import { defuFn } from 'defu'
const inlineConfig = {
"nuxt": {}
}
// Vite - webpack is handled directly in #app/config
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
_replaceAppConfig(newModule.default)
})
}
export default /*@__PURE__*/ defuFn(inlineConfig)

View File

@@ -0,0 +1,434 @@
import type { DefineComponent, SlotsType } from 'vue'
type IslandComponent<T> = DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>> & T
type HydrationStrategies = {
hydrateOnVisible?: IntersectionObserverInit | true
hydrateOnIdle?: number | true
hydrateOnInteraction?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | true
hydrateOnMediaQuery?: string
hydrateAfter?: number
hydrateWhen?: boolean
hydrateNever?: true
}
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
export const AiLogsTile: typeof import("../components/AiLogsTile.vue")['default']
export const FinancialTile: typeof import("../components/FinancialTile.vue")['default']
export const SalespersonTile: typeof import("../components/SalespersonTile.vue")['default']
export const ServiceMapTile: typeof import("../components/ServiceMapTile.vue")['default']
export const SystemHealthTile: typeof import("../components/SystemHealthTile.vue")['default']
export const TileCard: typeof import("../components/TileCard.vue")['default']
export const TileWrapper: typeof import("../components/TileWrapper.vue")['default']
export const MapServiceMap: typeof import("../components/map/ServiceMap.vue")['default']
export const NuxtWelcome: typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']
export const NuxtLayout: typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']
export const NuxtErrorBoundary: typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
export const ClientOnly: typeof import("../node_modules/nuxt/dist/app/components/client-only")['default']
export const DevOnly: typeof import("../node_modules/nuxt/dist/app/components/dev-only")['default']
export const ServerPlaceholder: typeof import("../node_modules/nuxt/dist/app/components/server-placeholder")['default']
export const NuxtLink: typeof import("../node_modules/nuxt/dist/app/components/nuxt-link")['default']
export const NuxtLoadingIndicator: typeof import("../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']
export const NuxtTime: typeof import("../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']
export const NuxtRouteAnnouncer: typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']
export const NuxtImg: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']
export const NuxtPicture: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']
export const VAvatar: typeof import("vuetify/components")['VAvatar']
export const VBanner: typeof import("vuetify/components")['VBanner']
export const VBannerActions: typeof import("vuetify/components")['VBannerActions']
export const VBannerText: typeof import("vuetify/components")['VBannerText']
export const VApp: typeof import("vuetify/components")['VApp']
export const VAppBar: typeof import("vuetify/components")['VAppBar']
export const VAppBarNavIcon: typeof import("vuetify/components")['VAppBarNavIcon']
export const VAppBarTitle: typeof import("vuetify/components")['VAppBarTitle']
export const VCalendar: typeof import("vuetify/components")['VCalendar']
export const VAlert: typeof import("vuetify/components")['VAlert']
export const VAlertTitle: typeof import("vuetify/components")['VAlertTitle']
export const VBtnToggle: typeof import("vuetify/components")['VBtnToggle']
export const VBreadcrumbs: typeof import("vuetify/components")['VBreadcrumbs']
export const VBreadcrumbsItem: typeof import("vuetify/components")['VBreadcrumbsItem']
export const VBreadcrumbsDivider: typeof import("vuetify/components")['VBreadcrumbsDivider']
export const VBtnGroup: typeof import("vuetify/components")['VBtnGroup']
export const VBtn: typeof import("vuetify/components")['VBtn']
export const VBadge: typeof import("vuetify/components")['VBadge']
export const VBottomNavigation: typeof import("vuetify/components")['VBottomNavigation']
export const VCheckbox: typeof import("vuetify/components")['VCheckbox']
export const VCheckboxBtn: typeof import("vuetify/components")['VCheckboxBtn']
export const VCarousel: typeof import("vuetify/components")['VCarousel']
export const VCarouselItem: typeof import("vuetify/components")['VCarouselItem']
export const VChip: typeof import("vuetify/components")['VChip']
export const VCard: typeof import("vuetify/components")['VCard']
export const VCardActions: typeof import("vuetify/components")['VCardActions']
export const VCardItem: typeof import("vuetify/components")['VCardItem']
export const VCardSubtitle: typeof import("vuetify/components")['VCardSubtitle']
export const VCardText: typeof import("vuetify/components")['VCardText']
export const VCardTitle: typeof import("vuetify/components")['VCardTitle']
export const VBottomSheet: typeof import("vuetify/components")['VBottomSheet']
export const VChipGroup: typeof import("vuetify/components")['VChipGroup']
export const VColorPicker: typeof import("vuetify/components")['VColorPicker']
export const VCombobox: typeof import("vuetify/components")['VCombobox']
export const VCode: typeof import("vuetify/components")['VCode']
export const VCounter: typeof import("vuetify/components")['VCounter']
export const VDatePicker: typeof import("vuetify/components")['VDatePicker']
export const VDatePickerControls: typeof import("vuetify/components")['VDatePickerControls']
export const VDatePickerHeader: typeof import("vuetify/components")['VDatePickerHeader']
export const VDatePickerMonth: typeof import("vuetify/components")['VDatePickerMonth']
export const VDatePickerMonths: typeof import("vuetify/components")['VDatePickerMonths']
export const VDatePickerYears: typeof import("vuetify/components")['VDatePickerYears']
export const VDialog: typeof import("vuetify/components")['VDialog']
export const VDivider: typeof import("vuetify/components")['VDivider']
export const VFab: typeof import("vuetify/components")['VFab']
export const VField: typeof import("vuetify/components")['VField']
export const VFieldLabel: typeof import("vuetify/components")['VFieldLabel']
export const VEmptyState: typeof import("vuetify/components")['VEmptyState']
export const VExpansionPanels: typeof import("vuetify/components")['VExpansionPanels']
export const VExpansionPanel: typeof import("vuetify/components")['VExpansionPanel']
export const VExpansionPanelText: typeof import("vuetify/components")['VExpansionPanelText']
export const VExpansionPanelTitle: typeof import("vuetify/components")['VExpansionPanelTitle']
export const VDataTable: typeof import("vuetify/components")['VDataTable']
export const VDataTableHeaders: typeof import("vuetify/components")['VDataTableHeaders']
export const VDataTableFooter: typeof import("vuetify/components")['VDataTableFooter']
export const VDataTableRows: typeof import("vuetify/components")['VDataTableRows']
export const VDataTableRow: typeof import("vuetify/components")['VDataTableRow']
export const VDataTableVirtual: typeof import("vuetify/components")['VDataTableVirtual']
export const VDataTableServer: typeof import("vuetify/components")['VDataTableServer']
export const VHotkey: typeof import("vuetify/components")['VHotkey']
export const VFileInput: typeof import("vuetify/components")['VFileInput']
export const VFooter: typeof import("vuetify/components")['VFooter']
export const VImg: typeof import("vuetify/components")['VImg']
export const VItemGroup: typeof import("vuetify/components")['VItemGroup']
export const VItem: typeof import("vuetify/components")['VItem']
export const VIcon: typeof import("vuetify/components")['VIcon']
export const VComponentIcon: typeof import("vuetify/components")['VComponentIcon']
export const VSvgIcon: typeof import("vuetify/components")['VSvgIcon']
export const VLigatureIcon: typeof import("vuetify/components")['VLigatureIcon']
export const VClassIcon: typeof import("vuetify/components")['VClassIcon']
export const VInput: typeof import("vuetify/components")['VInput']
export const VInfiniteScroll: typeof import("vuetify/components")['VInfiniteScroll']
export const VKbd: typeof import("vuetify/components")['VKbd']
export const VMenu: typeof import("vuetify/components")['VMenu']
export const VNavigationDrawer: typeof import("vuetify/components")['VNavigationDrawer']
export const VLabel: typeof import("vuetify/components")['VLabel']
export const VMain: typeof import("vuetify/components")['VMain']
export const VMessages: typeof import("vuetify/components")['VMessages']
export const VOverlay: typeof import("vuetify/components")['VOverlay']
export const VList: typeof import("vuetify/components")['VList']
export const VListGroup: typeof import("vuetify/components")['VListGroup']
export const VListImg: typeof import("vuetify/components")['VListImg']
export const VListItem: typeof import("vuetify/components")['VListItem']
export const VListItemAction: typeof import("vuetify/components")['VListItemAction']
export const VListItemMedia: typeof import("vuetify/components")['VListItemMedia']
export const VListItemSubtitle: typeof import("vuetify/components")['VListItemSubtitle']
export const VListItemTitle: typeof import("vuetify/components")['VListItemTitle']
export const VListSubheader: typeof import("vuetify/components")['VListSubheader']
export const VPagination: typeof import("vuetify/components")['VPagination']
export const VNumberInput: typeof import("vuetify/components")['VNumberInput']
export const VProgressLinear: typeof import("vuetify/components")['VProgressLinear']
export const VOtpInput: typeof import("vuetify/components")['VOtpInput']
export const VRadioGroup: typeof import("vuetify/components")['VRadioGroup']
export const VSelectionControl: typeof import("vuetify/components")['VSelectionControl']
export const VProgressCircular: typeof import("vuetify/components")['VProgressCircular']
export const VSelect: typeof import("vuetify/components")['VSelect']
export const VSheet: typeof import("vuetify/components")['VSheet']
export const VSelectionControlGroup: typeof import("vuetify/components")['VSelectionControlGroup']
export const VSlideGroup: typeof import("vuetify/components")['VSlideGroup']
export const VSlideGroupItem: typeof import("vuetify/components")['VSlideGroupItem']
export const VSkeletonLoader: typeof import("vuetify/components")['VSkeletonLoader']
export const VRating: typeof import("vuetify/components")['VRating']
export const VSnackbar: typeof import("vuetify/components")['VSnackbar']
export const VTextarea: typeof import("vuetify/components")['VTextarea']
export const VSystemBar: typeof import("vuetify/components")['VSystemBar']
export const VSwitch: typeof import("vuetify/components")['VSwitch']
export const VStepper: typeof import("vuetify/components")['VStepper']
export const VStepperActions: typeof import("vuetify/components")['VStepperActions']
export const VStepperHeader: typeof import("vuetify/components")['VStepperHeader']
export const VStepperItem: typeof import("vuetify/components")['VStepperItem']
export const VStepperWindow: typeof import("vuetify/components")['VStepperWindow']
export const VStepperWindowItem: typeof import("vuetify/components")['VStepperWindowItem']
export const VSlider: typeof import("vuetify/components")['VSlider']
export const VTab: typeof import("vuetify/components")['VTab']
export const VTabs: typeof import("vuetify/components")['VTabs']
export const VTabsWindow: typeof import("vuetify/components")['VTabsWindow']
export const VTabsWindowItem: typeof import("vuetify/components")['VTabsWindowItem']
export const VTable: typeof import("vuetify/components")['VTable']
export const VTimeline: typeof import("vuetify/components")['VTimeline']
export const VTimelineItem: typeof import("vuetify/components")['VTimelineItem']
export const VTextField: typeof import("vuetify/components")['VTextField']
export const VTooltip: typeof import("vuetify/components")['VTooltip']
export const VToolbar: typeof import("vuetify/components")['VToolbar']
export const VToolbarTitle: typeof import("vuetify/components")['VToolbarTitle']
export const VToolbarItems: typeof import("vuetify/components")['VToolbarItems']
export const VWindow: typeof import("vuetify/components")['VWindow']
export const VWindowItem: typeof import("vuetify/components")['VWindowItem']
export const VTimePicker: typeof import("vuetify/components")['VTimePicker']
export const VTimePickerClock: typeof import("vuetify/components")['VTimePickerClock']
export const VTimePickerControls: typeof import("vuetify/components")['VTimePickerControls']
export const VTreeview: typeof import("vuetify/components")['VTreeview']
export const VTreeviewItem: typeof import("vuetify/components")['VTreeviewItem']
export const VTreeviewGroup: typeof import("vuetify/components")['VTreeviewGroup']
export const VConfirmEdit: typeof import("vuetify/components")['VConfirmEdit']
export const VDataIterator: typeof import("vuetify/components")['VDataIterator']
export const VDefaultsProvider: typeof import("vuetify/components")['VDefaultsProvider']
export const VContainer: typeof import("vuetify/components")['VContainer']
export const VCol: typeof import("vuetify/components")['VCol']
export const VRow: typeof import("vuetify/components")['VRow']
export const VSpacer: typeof import("vuetify/components")['VSpacer']
export const VForm: typeof import("vuetify/components")['VForm']
export const VAutocomplete: typeof import("vuetify/components")['VAutocomplete']
export const VHover: typeof import("vuetify/components")['VHover']
export const VLazy: typeof import("vuetify/components")['VLazy']
export const VLayout: typeof import("vuetify/components")['VLayout']
export const VLayoutItem: typeof import("vuetify/components")['VLayoutItem']
export const VLocaleProvider: typeof import("vuetify/components")['VLocaleProvider']
export const VRadio: typeof import("vuetify/components")['VRadio']
export const VParallax: typeof import("vuetify/components")['VParallax']
export const VNoSsr: typeof import("vuetify/components")['VNoSsr']
export const VRangeSlider: typeof import("vuetify/components")['VRangeSlider']
export const VResponsive: typeof import("vuetify/components")['VResponsive']
export const VSnackbarQueue: typeof import("vuetify/components")['VSnackbarQueue']
export const VSpeedDial: typeof import("vuetify/components")['VSpeedDial']
export const VSparkline: typeof import("vuetify/components")['VSparkline']
export const VVirtualScroll: typeof import("vuetify/components")['VVirtualScroll']
export const VThemeProvider: typeof import("vuetify/components")['VThemeProvider']
export const VFabTransition: typeof import("vuetify/components")['VFabTransition']
export const VDialogBottomTransition: typeof import("vuetify/components")['VDialogBottomTransition']
export const VDialogTopTransition: typeof import("vuetify/components")['VDialogTopTransition']
export const VFadeTransition: typeof import("vuetify/components")['VFadeTransition']
export const VScaleTransition: typeof import("vuetify/components")['VScaleTransition']
export const VScrollXTransition: typeof import("vuetify/components")['VScrollXTransition']
export const VScrollXReverseTransition: typeof import("vuetify/components")['VScrollXReverseTransition']
export const VScrollYTransition: typeof import("vuetify/components")['VScrollYTransition']
export const VScrollYReverseTransition: typeof import("vuetify/components")['VScrollYReverseTransition']
export const VSlideXTransition: typeof import("vuetify/components")['VSlideXTransition']
export const VSlideXReverseTransition: typeof import("vuetify/components")['VSlideXReverseTransition']
export const VSlideYTransition: typeof import("vuetify/components")['VSlideYTransition']
export const VSlideYReverseTransition: typeof import("vuetify/components")['VSlideYReverseTransition']
export const VExpandTransition: typeof import("vuetify/components")['VExpandTransition']
export const VExpandXTransition: typeof import("vuetify/components")['VExpandXTransition']
export const VExpandBothTransition: typeof import("vuetify/components")['VExpandBothTransition']
export const VDialogTransition: typeof import("vuetify/components")['VDialogTransition']
export const VValidation: typeof import("vuetify/components")['VValidation']
export const NuxtLinkLocale: typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/NuxtLinkLocale")['default']
export const SwitchLocalePathLink: typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/SwitchLocalePathLink")['default']
export const NuxtPage: typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']
export const NoScript: typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']
export const Link: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']
export const Base: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Base']
export const Title: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Title']
export const Meta: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Meta']
export const Style: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Style']
export const Head: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Head']
export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
export const LazyAiLogsTile: LazyComponent<typeof import("../components/AiLogsTile.vue")['default']>
export const LazyFinancialTile: LazyComponent<typeof import("../components/FinancialTile.vue")['default']>
export const LazySalespersonTile: LazyComponent<typeof import("../components/SalespersonTile.vue")['default']>
export const LazyServiceMapTile: LazyComponent<typeof import("../components/ServiceMapTile.vue")['default']>
export const LazySystemHealthTile: LazyComponent<typeof import("../components/SystemHealthTile.vue")['default']>
export const LazyTileCard: LazyComponent<typeof import("../components/TileCard.vue")['default']>
export const LazyTileWrapper: LazyComponent<typeof import("../components/TileWrapper.vue")['default']>
export const LazyMapServiceMap: LazyComponent<typeof import("../components/map/ServiceMap.vue")['default']>
export const LazyNuxtWelcome: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']>
export const LazyNuxtLayout: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']>
export const LazyNuxtErrorBoundary: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>
export const LazyClientOnly: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/client-only")['default']>
export const LazyDevOnly: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/dev-only")['default']>
export const LazyServerPlaceholder: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/server-placeholder")['default']>
export const LazyNuxtLink: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-link")['default']>
export const LazyNuxtLoadingIndicator: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']>
export const LazyNuxtTime: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']>
export const LazyNuxtRouteAnnouncer: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']>
export const LazyNuxtImg: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']>
export const LazyNuxtPicture: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']>
export const LazyVAvatar: LazyComponent<typeof import("vuetify/components")['VAvatar']>
export const LazyVBanner: LazyComponent<typeof import("vuetify/components")['VBanner']>
export const LazyVBannerActions: LazyComponent<typeof import("vuetify/components")['VBannerActions']>
export const LazyVBannerText: LazyComponent<typeof import("vuetify/components")['VBannerText']>
export const LazyVApp: LazyComponent<typeof import("vuetify/components")['VApp']>
export const LazyVAppBar: LazyComponent<typeof import("vuetify/components")['VAppBar']>
export const LazyVAppBarNavIcon: LazyComponent<typeof import("vuetify/components")['VAppBarNavIcon']>
export const LazyVAppBarTitle: LazyComponent<typeof import("vuetify/components")['VAppBarTitle']>
export const LazyVCalendar: LazyComponent<typeof import("vuetify/components")['VCalendar']>
export const LazyVAlert: LazyComponent<typeof import("vuetify/components")['VAlert']>
export const LazyVAlertTitle: LazyComponent<typeof import("vuetify/components")['VAlertTitle']>
export const LazyVBtnToggle: LazyComponent<typeof import("vuetify/components")['VBtnToggle']>
export const LazyVBreadcrumbs: LazyComponent<typeof import("vuetify/components")['VBreadcrumbs']>
export const LazyVBreadcrumbsItem: LazyComponent<typeof import("vuetify/components")['VBreadcrumbsItem']>
export const LazyVBreadcrumbsDivider: LazyComponent<typeof import("vuetify/components")['VBreadcrumbsDivider']>
export const LazyVBtnGroup: LazyComponent<typeof import("vuetify/components")['VBtnGroup']>
export const LazyVBtn: LazyComponent<typeof import("vuetify/components")['VBtn']>
export const LazyVBadge: LazyComponent<typeof import("vuetify/components")['VBadge']>
export const LazyVBottomNavigation: LazyComponent<typeof import("vuetify/components")['VBottomNavigation']>
export const LazyVCheckbox: LazyComponent<typeof import("vuetify/components")['VCheckbox']>
export const LazyVCheckboxBtn: LazyComponent<typeof import("vuetify/components")['VCheckboxBtn']>
export const LazyVCarousel: LazyComponent<typeof import("vuetify/components")['VCarousel']>
export const LazyVCarouselItem: LazyComponent<typeof import("vuetify/components")['VCarouselItem']>
export const LazyVChip: LazyComponent<typeof import("vuetify/components")['VChip']>
export const LazyVCard: LazyComponent<typeof import("vuetify/components")['VCard']>
export const LazyVCardActions: LazyComponent<typeof import("vuetify/components")['VCardActions']>
export const LazyVCardItem: LazyComponent<typeof import("vuetify/components")['VCardItem']>
export const LazyVCardSubtitle: LazyComponent<typeof import("vuetify/components")['VCardSubtitle']>
export const LazyVCardText: LazyComponent<typeof import("vuetify/components")['VCardText']>
export const LazyVCardTitle: LazyComponent<typeof import("vuetify/components")['VCardTitle']>
export const LazyVBottomSheet: LazyComponent<typeof import("vuetify/components")['VBottomSheet']>
export const LazyVChipGroup: LazyComponent<typeof import("vuetify/components")['VChipGroup']>
export const LazyVColorPicker: LazyComponent<typeof import("vuetify/components")['VColorPicker']>
export const LazyVCombobox: LazyComponent<typeof import("vuetify/components")['VCombobox']>
export const LazyVCode: LazyComponent<typeof import("vuetify/components")['VCode']>
export const LazyVCounter: LazyComponent<typeof import("vuetify/components")['VCounter']>
export const LazyVDatePicker: LazyComponent<typeof import("vuetify/components")['VDatePicker']>
export const LazyVDatePickerControls: LazyComponent<typeof import("vuetify/components")['VDatePickerControls']>
export const LazyVDatePickerHeader: LazyComponent<typeof import("vuetify/components")['VDatePickerHeader']>
export const LazyVDatePickerMonth: LazyComponent<typeof import("vuetify/components")['VDatePickerMonth']>
export const LazyVDatePickerMonths: LazyComponent<typeof import("vuetify/components")['VDatePickerMonths']>
export const LazyVDatePickerYears: LazyComponent<typeof import("vuetify/components")['VDatePickerYears']>
export const LazyVDialog: LazyComponent<typeof import("vuetify/components")['VDialog']>
export const LazyVDivider: LazyComponent<typeof import("vuetify/components")['VDivider']>
export const LazyVFab: LazyComponent<typeof import("vuetify/components")['VFab']>
export const LazyVField: LazyComponent<typeof import("vuetify/components")['VField']>
export const LazyVFieldLabel: LazyComponent<typeof import("vuetify/components")['VFieldLabel']>
export const LazyVEmptyState: LazyComponent<typeof import("vuetify/components")['VEmptyState']>
export const LazyVExpansionPanels: LazyComponent<typeof import("vuetify/components")['VExpansionPanels']>
export const LazyVExpansionPanel: LazyComponent<typeof import("vuetify/components")['VExpansionPanel']>
export const LazyVExpansionPanelText: LazyComponent<typeof import("vuetify/components")['VExpansionPanelText']>
export const LazyVExpansionPanelTitle: LazyComponent<typeof import("vuetify/components")['VExpansionPanelTitle']>
export const LazyVDataTable: LazyComponent<typeof import("vuetify/components")['VDataTable']>
export const LazyVDataTableHeaders: LazyComponent<typeof import("vuetify/components")['VDataTableHeaders']>
export const LazyVDataTableFooter: LazyComponent<typeof import("vuetify/components")['VDataTableFooter']>
export const LazyVDataTableRows: LazyComponent<typeof import("vuetify/components")['VDataTableRows']>
export const LazyVDataTableRow: LazyComponent<typeof import("vuetify/components")['VDataTableRow']>
export const LazyVDataTableVirtual: LazyComponent<typeof import("vuetify/components")['VDataTableVirtual']>
export const LazyVDataTableServer: LazyComponent<typeof import("vuetify/components")['VDataTableServer']>
export const LazyVHotkey: LazyComponent<typeof import("vuetify/components")['VHotkey']>
export const LazyVFileInput: LazyComponent<typeof import("vuetify/components")['VFileInput']>
export const LazyVFooter: LazyComponent<typeof import("vuetify/components")['VFooter']>
export const LazyVImg: LazyComponent<typeof import("vuetify/components")['VImg']>
export const LazyVItemGroup: LazyComponent<typeof import("vuetify/components")['VItemGroup']>
export const LazyVItem: LazyComponent<typeof import("vuetify/components")['VItem']>
export const LazyVIcon: LazyComponent<typeof import("vuetify/components")['VIcon']>
export const LazyVComponentIcon: LazyComponent<typeof import("vuetify/components")['VComponentIcon']>
export const LazyVSvgIcon: LazyComponent<typeof import("vuetify/components")['VSvgIcon']>
export const LazyVLigatureIcon: LazyComponent<typeof import("vuetify/components")['VLigatureIcon']>
export const LazyVClassIcon: LazyComponent<typeof import("vuetify/components")['VClassIcon']>
export const LazyVInput: LazyComponent<typeof import("vuetify/components")['VInput']>
export const LazyVInfiniteScroll: LazyComponent<typeof import("vuetify/components")['VInfiniteScroll']>
export const LazyVKbd: LazyComponent<typeof import("vuetify/components")['VKbd']>
export const LazyVMenu: LazyComponent<typeof import("vuetify/components")['VMenu']>
export const LazyVNavigationDrawer: LazyComponent<typeof import("vuetify/components")['VNavigationDrawer']>
export const LazyVLabel: LazyComponent<typeof import("vuetify/components")['VLabel']>
export const LazyVMain: LazyComponent<typeof import("vuetify/components")['VMain']>
export const LazyVMessages: LazyComponent<typeof import("vuetify/components")['VMessages']>
export const LazyVOverlay: LazyComponent<typeof import("vuetify/components")['VOverlay']>
export const LazyVList: LazyComponent<typeof import("vuetify/components")['VList']>
export const LazyVListGroup: LazyComponent<typeof import("vuetify/components")['VListGroup']>
export const LazyVListImg: LazyComponent<typeof import("vuetify/components")['VListImg']>
export const LazyVListItem: LazyComponent<typeof import("vuetify/components")['VListItem']>
export const LazyVListItemAction: LazyComponent<typeof import("vuetify/components")['VListItemAction']>
export const LazyVListItemMedia: LazyComponent<typeof import("vuetify/components")['VListItemMedia']>
export const LazyVListItemSubtitle: LazyComponent<typeof import("vuetify/components")['VListItemSubtitle']>
export const LazyVListItemTitle: LazyComponent<typeof import("vuetify/components")['VListItemTitle']>
export const LazyVListSubheader: LazyComponent<typeof import("vuetify/components")['VListSubheader']>
export const LazyVPagination: LazyComponent<typeof import("vuetify/components")['VPagination']>
export const LazyVNumberInput: LazyComponent<typeof import("vuetify/components")['VNumberInput']>
export const LazyVProgressLinear: LazyComponent<typeof import("vuetify/components")['VProgressLinear']>
export const LazyVOtpInput: LazyComponent<typeof import("vuetify/components")['VOtpInput']>
export const LazyVRadioGroup: LazyComponent<typeof import("vuetify/components")['VRadioGroup']>
export const LazyVSelectionControl: LazyComponent<typeof import("vuetify/components")['VSelectionControl']>
export const LazyVProgressCircular: LazyComponent<typeof import("vuetify/components")['VProgressCircular']>
export const LazyVSelect: LazyComponent<typeof import("vuetify/components")['VSelect']>
export const LazyVSheet: LazyComponent<typeof import("vuetify/components")['VSheet']>
export const LazyVSelectionControlGroup: LazyComponent<typeof import("vuetify/components")['VSelectionControlGroup']>
export const LazyVSlideGroup: LazyComponent<typeof import("vuetify/components")['VSlideGroup']>
export const LazyVSlideGroupItem: LazyComponent<typeof import("vuetify/components")['VSlideGroupItem']>
export const LazyVSkeletonLoader: LazyComponent<typeof import("vuetify/components")['VSkeletonLoader']>
export const LazyVRating: LazyComponent<typeof import("vuetify/components")['VRating']>
export const LazyVSnackbar: LazyComponent<typeof import("vuetify/components")['VSnackbar']>
export const LazyVTextarea: LazyComponent<typeof import("vuetify/components")['VTextarea']>
export const LazyVSystemBar: LazyComponent<typeof import("vuetify/components")['VSystemBar']>
export const LazyVSwitch: LazyComponent<typeof import("vuetify/components")['VSwitch']>
export const LazyVStepper: LazyComponent<typeof import("vuetify/components")['VStepper']>
export const LazyVStepperActions: LazyComponent<typeof import("vuetify/components")['VStepperActions']>
export const LazyVStepperHeader: LazyComponent<typeof import("vuetify/components")['VStepperHeader']>
export const LazyVStepperItem: LazyComponent<typeof import("vuetify/components")['VStepperItem']>
export const LazyVStepperWindow: LazyComponent<typeof import("vuetify/components")['VStepperWindow']>
export const LazyVStepperWindowItem: LazyComponent<typeof import("vuetify/components")['VStepperWindowItem']>
export const LazyVSlider: LazyComponent<typeof import("vuetify/components")['VSlider']>
export const LazyVTab: LazyComponent<typeof import("vuetify/components")['VTab']>
export const LazyVTabs: LazyComponent<typeof import("vuetify/components")['VTabs']>
export const LazyVTabsWindow: LazyComponent<typeof import("vuetify/components")['VTabsWindow']>
export const LazyVTabsWindowItem: LazyComponent<typeof import("vuetify/components")['VTabsWindowItem']>
export const LazyVTable: LazyComponent<typeof import("vuetify/components")['VTable']>
export const LazyVTimeline: LazyComponent<typeof import("vuetify/components")['VTimeline']>
export const LazyVTimelineItem: LazyComponent<typeof import("vuetify/components")['VTimelineItem']>
export const LazyVTextField: LazyComponent<typeof import("vuetify/components")['VTextField']>
export const LazyVTooltip: LazyComponent<typeof import("vuetify/components")['VTooltip']>
export const LazyVToolbar: LazyComponent<typeof import("vuetify/components")['VToolbar']>
export const LazyVToolbarTitle: LazyComponent<typeof import("vuetify/components")['VToolbarTitle']>
export const LazyVToolbarItems: LazyComponent<typeof import("vuetify/components")['VToolbarItems']>
export const LazyVWindow: LazyComponent<typeof import("vuetify/components")['VWindow']>
export const LazyVWindowItem: LazyComponent<typeof import("vuetify/components")['VWindowItem']>
export const LazyVTimePicker: LazyComponent<typeof import("vuetify/components")['VTimePicker']>
export const LazyVTimePickerClock: LazyComponent<typeof import("vuetify/components")['VTimePickerClock']>
export const LazyVTimePickerControls: LazyComponent<typeof import("vuetify/components")['VTimePickerControls']>
export const LazyVTreeview: LazyComponent<typeof import("vuetify/components")['VTreeview']>
export const LazyVTreeviewItem: LazyComponent<typeof import("vuetify/components")['VTreeviewItem']>
export const LazyVTreeviewGroup: LazyComponent<typeof import("vuetify/components")['VTreeviewGroup']>
export const LazyVConfirmEdit: LazyComponent<typeof import("vuetify/components")['VConfirmEdit']>
export const LazyVDataIterator: LazyComponent<typeof import("vuetify/components")['VDataIterator']>
export const LazyVDefaultsProvider: LazyComponent<typeof import("vuetify/components")['VDefaultsProvider']>
export const LazyVContainer: LazyComponent<typeof import("vuetify/components")['VContainer']>
export const LazyVCol: LazyComponent<typeof import("vuetify/components")['VCol']>
export const LazyVRow: LazyComponent<typeof import("vuetify/components")['VRow']>
export const LazyVSpacer: LazyComponent<typeof import("vuetify/components")['VSpacer']>
export const LazyVForm: LazyComponent<typeof import("vuetify/components")['VForm']>
export const LazyVAutocomplete: LazyComponent<typeof import("vuetify/components")['VAutocomplete']>
export const LazyVHover: LazyComponent<typeof import("vuetify/components")['VHover']>
export const LazyVLazy: LazyComponent<typeof import("vuetify/components")['VLazy']>
export const LazyVLayout: LazyComponent<typeof import("vuetify/components")['VLayout']>
export const LazyVLayoutItem: LazyComponent<typeof import("vuetify/components")['VLayoutItem']>
export const LazyVLocaleProvider: LazyComponent<typeof import("vuetify/components")['VLocaleProvider']>
export const LazyVRadio: LazyComponent<typeof import("vuetify/components")['VRadio']>
export const LazyVParallax: LazyComponent<typeof import("vuetify/components")['VParallax']>
export const LazyVNoSsr: LazyComponent<typeof import("vuetify/components")['VNoSsr']>
export const LazyVRangeSlider: LazyComponent<typeof import("vuetify/components")['VRangeSlider']>
export const LazyVResponsive: LazyComponent<typeof import("vuetify/components")['VResponsive']>
export const LazyVSnackbarQueue: LazyComponent<typeof import("vuetify/components")['VSnackbarQueue']>
export const LazyVSpeedDial: LazyComponent<typeof import("vuetify/components")['VSpeedDial']>
export const LazyVSparkline: LazyComponent<typeof import("vuetify/components")['VSparkline']>
export const LazyVVirtualScroll: LazyComponent<typeof import("vuetify/components")['VVirtualScroll']>
export const LazyVThemeProvider: LazyComponent<typeof import("vuetify/components")['VThemeProvider']>
export const LazyVFabTransition: LazyComponent<typeof import("vuetify/components")['VFabTransition']>
export const LazyVDialogBottomTransition: LazyComponent<typeof import("vuetify/components")['VDialogBottomTransition']>
export const LazyVDialogTopTransition: LazyComponent<typeof import("vuetify/components")['VDialogTopTransition']>
export const LazyVFadeTransition: LazyComponent<typeof import("vuetify/components")['VFadeTransition']>
export const LazyVScaleTransition: LazyComponent<typeof import("vuetify/components")['VScaleTransition']>
export const LazyVScrollXTransition: LazyComponent<typeof import("vuetify/components")['VScrollXTransition']>
export const LazyVScrollXReverseTransition: LazyComponent<typeof import("vuetify/components")['VScrollXReverseTransition']>
export const LazyVScrollYTransition: LazyComponent<typeof import("vuetify/components")['VScrollYTransition']>
export const LazyVScrollYReverseTransition: LazyComponent<typeof import("vuetify/components")['VScrollYReverseTransition']>
export const LazyVSlideXTransition: LazyComponent<typeof import("vuetify/components")['VSlideXTransition']>
export const LazyVSlideXReverseTransition: LazyComponent<typeof import("vuetify/components")['VSlideXReverseTransition']>
export const LazyVSlideYTransition: LazyComponent<typeof import("vuetify/components")['VSlideYTransition']>
export const LazyVSlideYReverseTransition: LazyComponent<typeof import("vuetify/components")['VSlideYReverseTransition']>
export const LazyVExpandTransition: LazyComponent<typeof import("vuetify/components")['VExpandTransition']>
export const LazyVExpandXTransition: LazyComponent<typeof import("vuetify/components")['VExpandXTransition']>
export const LazyVExpandBothTransition: LazyComponent<typeof import("vuetify/components")['VExpandBothTransition']>
export const LazyVDialogTransition: LazyComponent<typeof import("vuetify/components")['VDialogTransition']>
export const LazyVValidation: LazyComponent<typeof import("vuetify/components")['VValidation']>
export const LazyNuxtLinkLocale: LazyComponent<typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/NuxtLinkLocale")['default']>
export const LazySwitchLocalePathLink: LazyComponent<typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/SwitchLocalePathLink")['default']>
export const LazyNuxtPage: LazyComponent<typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']>
export const LazyNoScript: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']>
export const LazyLink: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']>
export const LazyBase: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Base']>
export const LazyTitle: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Title']>
export const LazyMeta: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Meta']>
export const LazyStyle: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Style']>
export const LazyHead: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Head']>
export const LazyHtml: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']>
export const LazyBody: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']>
export const LazyNuxtIsland: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']>
export const componentNames: string[]

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,119 @@
// @ts-nocheck
export const localeCodes = [
"en",
"hu"
]
export const localeLoaders = {
"en": [{ key: "../locales/en.json", load: () => import("../locales/en.json" /* webpackChunkName: "locale__app_locales_en_json" */), cache: true }],
"hu": [{ key: "../locales/hu.json", load: () => import("../locales/hu.json" /* webpackChunkName: "locale__app_locales_hu_json" */), cache: true }]
}
export const vueI18nConfigs = [
]
export const nuxtI18nOptions = {
"experimental": {
"localeDetector": "",
"switchLocalePathLinkSSR": false,
"autoImportTranslationFunctions": false
},
"bundle": {
"compositionOnly": true,
"runtimeOnly": false,
"fullInstall": true,
"dropMessageCompiler": false
},
"compilation": {
"jit": true,
"strictMessage": true,
"escapeHtml": false
},
"customBlocks": {
"defaultSFCLang": "json",
"globalSFCScope": false
},
"vueI18n": "",
"locales": [
{
"code": "en",
"name": "English",
"language": "en-US",
"files": [
"/app/locales/en.json"
]
},
{
"code": "hu",
"name": "Magyar",
"language": "hu-HU",
"files": [
"/app/locales/hu.json"
]
}
],
"defaultLocale": "hu",
"defaultDirection": "ltr",
"routesNameSeparator": "___",
"trailingSlash": false,
"defaultLocaleRouteNameSuffix": "default",
"strategy": "no_prefix",
"lazy": true,
"langDir": "locales",
"detectBrowserLanguage": {
"alwaysRedirect": false,
"cookieCrossOrigin": false,
"cookieDomain": null,
"cookieKey": "i18n_redirected",
"cookieSecure": false,
"fallbackLocale": "",
"redirectOn": "root",
"useCookie": true
},
"differentDomains": false,
"baseUrl": "",
"dynamicRouteParams": false,
"customRoutes": "page",
"pages": {},
"skipSettingLocaleOnNavigate": false,
"types": "composition",
"debug": false,
"parallelPlugin": false,
"multiDomainLocales": false,
"i18nModules": []
}
export const normalizedLocales = [
{
"code": "en",
"name": "English",
"language": "en-US",
"files": [
{
"path": "/app/locales/en.json"
}
]
},
{
"code": "hu",
"name": "Magyar",
"language": "hu-HU",
"files": [
{
"path": "/app/locales/hu.json"
}
]
}
]
export const NUXT_I18N_MODULE_ID = "@nuxtjs/i18n"
export const parallelPlugin = false
export const isSSG = false
export const DEFAULT_DYNAMIC_PARAMS_KEY = "nuxtI18n"
export const DEFAULT_COOKIE_KEY = "i18n_redirected"
export const SWITCH_LOCALE_PATH_LINK_IDENTIFIER = "nuxt-i18n-slp"

View File

@@ -0,0 +1,43 @@
export { useScriptTriggerConsent, useScriptEventPage, useScriptTriggerElement, useScript, useScriptGoogleAnalytics, useScriptPlausibleAnalytics, useScriptCrisp, useScriptClarity, useScriptCloudflareWebAnalytics, useScriptFathomAnalytics, useScriptMatomoAnalytics, useScriptGoogleTagManager, useScriptGoogleAdsense, useScriptSegment, useScriptMetaPixel, useScriptXPixel, useScriptIntercom, useScriptHotjar, useScriptStripe, useScriptLemonSqueezy, useScriptVimeoPlayer, useScriptYouTubePlayer, useScriptGoogleMaps, useScriptNpm, useScriptUmamiAnalytics, useScriptSnapchatPixel, useScriptRybbitAnalytics, useScriptDatabuddyAnalytics, useScriptRedditPixel, useScriptPayPal } from '#app/composables/script-stubs';
export { isVue2, isVue3 } from 'vue-demi';
export { defineNuxtLink } from '#app/components/nuxt-link';
export { useNuxtApp, tryUseNuxtApp, defineNuxtPlugin, definePayloadPlugin, useRuntimeConfig, defineAppConfig } from '#app/nuxt';
export { useAppConfig, updateAppConfig } from '#app/config';
export { defineNuxtComponent } from '#app/composables/component';
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from '#app/composables/asyncData';
export { useHydration } from '#app/composables/hydrate';
export { callOnce } from '#app/composables/once';
export { useState, clearNuxtState } from '#app/composables/state';
export { clearError, createError, isNuxtError, showError, useError } from '#app/composables/error';
export { useFetch, useLazyFetch } from '#app/composables/fetch';
export { useCookie, refreshCookie } from '#app/composables/cookie';
export { onPrehydrate, prerenderRoutes, useRequestHeader, useRequestHeaders, useResponseHeader, useRequestEvent, useRequestFetch, setResponseStatus } from '#app/composables/ssr';
export { onNuxtReady } from '#app/composables/ready';
export { preloadComponents, prefetchComponents, preloadRouteComponents } from '#app/composables/preload';
export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, setPageLayout, navigateTo, useRoute, useRouter } from '#app/composables/router';
export { isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver } from '#app/composables/payload';
export { useLoadingIndicator } from '#app/composables/loading-indicator';
export { getAppManifest, getRouteRules } from '#app/composables/manifest';
export { reloadNuxtApp } from '#app/composables/chunk';
export { useRequestURL } from '#app/composables/url';
export { usePreviewMode } from '#app/composables/preview';
export { useRouteAnnouncer } from '#app/composables/route-announcer';
export { useRuntimeHook } from '#app/composables/runtime-hook';
export { useHead, useHeadSafe, useServerHeadSafe, useServerHead, useSeoMeta, useServerSeoMeta, injectHead } from '#app/composables/head';
export { onBeforeRouteLeave, onBeforeRouteUpdate, useLink } from 'vue-router';
export { withCtx, withDirectives, withKeys, withMemo, withModifiers, withScopeId, onActivated, onBeforeMount, onBeforeUnmount, onBeforeUpdate, onDeactivated, onErrorCaptured, onMounted, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, computed, customRef, isProxy, isReactive, isReadonly, isRef, markRaw, proxyRefs, reactive, readonly, ref, shallowReactive, shallowReadonly, shallowRef, toRaw, toRef, toRefs, triggerRef, unref, watch, watchEffect, watchPostEffect, watchSyncEffect, onWatcherCleanup, isShallow, effect, effectScope, getCurrentScope, onScopeDispose, defineComponent, defineAsyncComponent, resolveComponent, getCurrentInstance, h, inject, hasInjectionContext, nextTick, provide, toValue, useModel, useAttrs, useCssModule, useCssVars, useSlots, useTransitionState, useId, useTemplateRef, useShadowRoot, Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue';
export { requestIdleCallback, cancelIdleCallback } from '#app/compat/idle-callback';
export { setInterval } from '#app/compat/interval';
export { definePageMeta } from '../node_modules/nuxt/dist/pages/runtime/composables';
export { defineLazyHydrationComponent } from '#app/composables/lazy-hydration';
export { useHealthMonitor, HealthMetrics, SystemAlert, HealthMonitorState } from '../composables/useHealthMonitor';
export { default as usePolling, PollingOptions, PollingState } from '../composables/usePolling';
export { Role, Role, ScopeLevel, ScopeLevel, RoleRank, AdminTiles, useRBAC, TilePermission } from '../composables/useRBAC';
export { useServiceMap, Service, Scope } from '../composables/useServiceMap';
export { default as useUserManagement, UpdateUserRoleRequest, UserManagementState } from '../composables/useUserManagement';
export { useAuthStore, JwtPayload, User } from '../stores/auth';
export { useTileStore, UserTilePreference } from '../stores/tiles';
export { defineStore, acceptHMRUpdate, usePinia, storeToRefs } from '../node_modules/@pinia/nuxt/dist/runtime/composables';
export { useLocale, useDefaults, useDisplay, useLayout, useRtl, useTheme } from 'vuetify';
export { useI18n } from '../node_modules/vue-i18n/dist/vue-i18n';
export { useRouteBaseName, useLocalePath, useLocaleRoute, useSwitchLocalePath, useLocaleHead, useBrowserLocale, useCookieLocale, useSetI18nParams, defineI18nRoute, defineI18nLocale, defineI18nConfig } from '../node_modules/@nuxtjs/i18n/dist/runtime/composables/index';

View File

@@ -0,0 +1 @@
{"id":"dev","timestamp":1776057763271}

View File

@@ -0,0 +1 @@
{"id":"dev","timestamp":1776057763271,"prerendered":[]}

View File

@@ -0,0 +1,17 @@
{
"date": "2026-04-14T18:57:09.447Z",
"preset": "nitro-dev",
"framework": {
"name": "nuxt",
"version": "3.21.2"
},
"versions": {
"nitro": "2.13.2"
},
"dev": {
"pid": 19,
"workerAddress": {
"socketPath": "\u0000nitro-worker-19-2-2-2761.sock"
}
}
}

View File

@@ -0,0 +1,30 @@
/// <reference types="@pinia/nuxt" />
/// <reference types="vuetify-nuxt-module" />
/// <reference types="@nuxtjs/i18n" />
/// <reference types="@nuxt/telemetry" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference path="types/nitro-layouts.d.ts" />
/// <reference path="types/builder-env.d.ts" />
/// <reference types="nuxt" />
/// <reference path="types/app-defaults.d.ts" />
/// <reference path="types/plugins.d.ts" />
/// <reference path="types/build.d.ts" />
/// <reference path="types/schema.d.ts" />
/// <reference path="types/app.config.d.ts" />
/// <reference path="../node_modules/@nuxt/vite-builder/dist/index.d.mts" />
/// <reference path="../node_modules/@nuxt/nitro-server/dist/index.d.mts" />
/// <reference types="@pinia/nuxt" />
/// <reference types="vuetify" />
/// <reference types="vuetify-nuxt-module/configuration" />
/// <reference path="types/i18n-plugin.d.ts" />
/// <reference types="vue-router" />
/// <reference path="types/middleware.d.ts" />
/// <reference path="types/nitro-middleware.d.ts" />
/// <reference path="types/layouts.d.ts" />
/// <reference path="types/components.d.ts" />
/// <reference path="imports.d.ts" />
/// <reference path="types/imports.d.ts" />
/// <reference path="schema/nuxt.schema.d.ts" />
/// <reference path="types/nitro.d.ts" />
export {}

View File

@@ -0,0 +1,9 @@
{
"_hash": "86WsHSzrghegd85QlSfb0tmyVB8WGKoWBHcdl2r1_DE",
"project": {
"rootDir": "/app"
},
"versions": {
"nuxt": "3.21.2"
}
}

View File

@@ -0,0 +1,17 @@
export interface NuxtCustomSchema {
}
export type CustomAppConfig = Exclude<NuxtCustomSchema['appConfig'], undefined>
type _CustomAppConfig = CustomAppConfig
declare module '@nuxt/schema' {
interface NuxtConfig extends Omit<NuxtCustomSchema, 'appConfig'> {}
interface NuxtOptions extends Omit<NuxtCustomSchema, 'appConfig'> {}
interface CustomAppConfig extends _CustomAppConfig {}
}
declare module 'nuxt/schema' {
interface NuxtConfig extends Omit<NuxtCustomSchema, 'appConfig'> {}
interface NuxtOptions extends Omit<NuxtCustomSchema, 'appConfig'> {}
interface CustomAppConfig extends _CustomAppConfig {}
}

View File

@@ -0,0 +1,3 @@
{
"id": "#"
}

View File

@@ -0,0 +1,13 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 4/13/2026, 5:22:43 AM
import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger";
;
const config = [
{"content":{"files":["/app/components/**/*.{vue,js,jsx,mjs,ts,tsx}","/app/components/global/**/*.{vue,js,jsx,mjs,ts,tsx}","/app/components/**/*.{vue,js,jsx,mjs,ts,tsx}","/app/layouts/**/*.{vue,js,jsx,mjs,ts,tsx}","/app/plugins/**/*.{js,ts,mjs}","/app/composables/**/*.{js,ts,mjs}","/app/utils/**/*.{js,ts,mjs}","/app/pages/**/*.{vue,js,jsx,mjs,ts,tsx}","/app/{A,a}pp.{vue,js,jsx,mjs,ts,tsx}","/app/{E,e}rror.{vue,js,jsx,mjs,ts,tsx}","/app/app.config.{js,ts,mjs}"]}},
{}
].reduce((acc, curr) => configMerger(acc, curr), {});
const resolvedConfig = config;
export default resolvedConfig;

View File

@@ -0,0 +1,199 @@
{
"compilerOptions": {
"paths": {
"@vue/runtime-core": [
"../node_modules/@vue/runtime-core"
],
"@vue/compiler-sfc": [
"../node_modules/@vue/compiler-sfc"
],
"unplugin-vue-router/client": [
"../node_modules/unplugin-vue-router/client"
],
"@nuxt/schema": [
"../node_modules/@nuxt/schema"
],
"nuxt": [
"../node_modules/nuxt"
],
"vite/client": [
"../node_modules/vite/client"
],
"nitropack/types": [
"../node_modules/nitropack/types"
],
"nitropack/runtime": [
"../node_modules/nitropack/runtime"
],
"nitropack": [
"../node_modules/nitropack"
],
"defu": [
"../node_modules/defu"
],
"h3": [
"../node_modules/h3"
],
"consola": [
"../node_modules/consola"
],
"ofetch": [
"../node_modules/ofetch"
],
"crossws": [
"../node_modules/crossws"
],
"~": [
".."
],
"~/*": [
"../*"
],
"@": [
".."
],
"@/*": [
"../*"
],
"~~": [
".."
],
"~~/*": [
"../*"
],
"@@": [
".."
],
"@@/*": [
"../*"
],
"#shared": [
"../shared"
],
"#shared/*": [
"../shared/*"
],
"assets": [
"../assets"
],
"assets/*": [
"../assets/*"
],
"public": [
"../public"
],
"public/*": [
"../public/*"
],
"#server": [
"../server"
],
"#server/*": [
"../server/*"
],
"#app": [
"../node_modules/nuxt/dist/app"
],
"#app/*": [
"../node_modules/nuxt/dist/app/*"
],
"vue-demi": [
"../node_modules/nuxt/dist/app/compat/vue-demi"
],
"pinia": [
"../node_modules/pinia/dist/pinia"
],
"vue-i18n": [
"../node_modules/vue-i18n/dist/vue-i18n"
],
"@intlify/shared": [
"../node_modules/@intlify/shared/dist/shared"
],
"@intlify/message-compiler": [
"../node_modules/@intlify/message-compiler/dist/message-compiler"
],
"@intlify/core-base": [
"../node_modules/@intlify/core-base/dist/core-base"
],
"@intlify/core": [
"../node_modules/@intlify/core/dist/core.node"
],
"@intlify/utils/h3": [
"../node_modules/@intlify/utils/dist/h3"
],
"ufo": [
"../node_modules/ufo/dist/index"
],
"is-https": [
"../node_modules/is-https/dist/index"
],
"#i18n": [
"../node_modules/@nuxtjs/i18n/dist/runtime/composables/index"
],
"#vue-router": [
"../node_modules/vue-router"
],
"#unhead/composables": [
"../node_modules/nuxt/dist/head/runtime/composables/v3"
],
"#imports": [
"./imports"
],
"#app-manifest": [
"./manifest/meta/dev"
],
"#components": [
"./components"
],
"#build": [
"."
],
"#build/*": [
"./*"
]
},
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ESNext",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowArbitraryExtensions": true,
"strict": true,
"noUncheckedIndexedAccess": false,
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"module": "preserve",
"noEmit": true,
"lib": [
"ESNext",
"dom",
"dom.iterable",
"webworker"
],
"jsx": "preserve",
"jsxImportSource": "vue",
"types": [],
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"noImplicitThis": true,
"allowSyntheticDefaultImports": true
},
"include": [
"../**/*",
"../.config/nuxt.*",
"./nuxt.d.ts",
"../node_modules/runtime",
"../node_modules/dist/runtime",
".."
],
"exclude": [
"../dist",
"../.data",
"../node_modules/runtime/server",
"../node_modules/dist/runtime/server",
"dev"
]
}

View File

@@ -0,0 +1,168 @@
{
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"resolveJsonModule": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"paths": {
"#imports": [
"./types/nitro-imports"
],
"~/*": [
"../*"
],
"@/*": [
"../*"
],
"~~/*": [
"../*"
],
"@@/*": [
"../*"
],
"@vue/runtime-core": [
"../node_modules/@vue/runtime-core"
],
"@vue/compiler-sfc": [
"../node_modules/@vue/compiler-sfc"
],
"unplugin-vue-router/client": [
"../node_modules/unplugin-vue-router/client"
],
"@nuxt/schema": [
"../node_modules/@nuxt/schema"
],
"nuxt": [
"../node_modules/nuxt"
],
"vite/client": [
"../node_modules/vite/client"
],
"nitropack/types": [
"../node_modules/nitropack/types"
],
"nitropack/runtime": [
"../node_modules/nitropack/runtime"
],
"nitropack": [
"../node_modules/nitropack"
],
"defu": [
"../node_modules/defu"
],
"h3": [
"../node_modules/h3"
],
"consola": [
"../node_modules/consola"
],
"ofetch": [
"../node_modules/ofetch"
],
"crossws": [
"../node_modules/crossws"
],
"#shared": [
"../shared"
],
"#shared/*": [
"../shared/*"
],
"assets": [
"../assets"
],
"assets/*": [
"../assets/*"
],
"public": [
"../public"
],
"public/*": [
"../public/*"
],
"#server": [
"../server"
],
"#server/*": [
"../server/*"
],
"#build": [
"./"
],
"#build/*": [
"./*"
],
"#internal/nuxt/paths": [
"../node_modules/@nuxt/nitro-server/dist/runtime/utils/paths"
],
"pinia": [
"../node_modules/pinia/dist/pinia"
],
"vue-i18n": [
"../node_modules/vue-i18n/dist/vue-i18n"
],
"@intlify/shared": [
"../node_modules/@intlify/shared/dist/shared"
],
"@intlify/message-compiler": [
"../node_modules/@intlify/message-compiler/dist/message-compiler"
],
"@intlify/core-base": [
"../node_modules/@intlify/core-base/dist/core-base"
],
"@intlify/core": [
"../node_modules/@intlify/core/dist/core.node"
],
"@intlify/utils/h3": [
"../node_modules/@intlify/utils/dist/h3"
],
"ufo": [
"../node_modules/ufo/dist/index"
],
"is-https": [
"../node_modules/is-https/dist/index"
],
"#i18n": [
"../node_modules/@nuxtjs/i18n/dist/runtime/composables/index"
],
"#unhead/composables": [
"../node_modules/nuxt/dist/head/runtime/composables/v3"
]
},
"lib": [
"esnext",
"webworker",
"dom.iterable"
],
"noUncheckedIndexedAccess": true,
"allowArbitraryExtensions": true
},
"include": [
"./types/nitro-nuxt.d.ts",
"../node_modules/runtime/server",
"../node_modules/dist/runtime/server",
"../server/**/*",
"../shared/**/*.d.ts",
"./types/nitro.d.ts",
"../**/*"
],
"exclude": [
"../node_modules",
"../node_modules/nuxt/node_modules",
"../node_modules/@pinia/nuxt/node_modules",
"../node_modules/@nuxtjs/tailwindcss/node_modules",
"../node_modules/vuetify-nuxt-module/node_modules",
"../node_modules/@nuxtjs/i18n/node_modules",
"../node_modules/@nuxt/telemetry/node_modules",
"../dist"
]
}

View File

@@ -0,0 +1,7 @@
declare module 'nuxt/app/defaults' {
type DefaultAsyncDataErrorValue = null
type DefaultAsyncDataValue = null
type DefaultErrorValue = null
type DedupeOption = boolean | 'cancel' | 'defer'
}

View File

@@ -0,0 +1,31 @@
import type { CustomAppConfig } from 'nuxt/schema'
import type { Defu } from 'defu'
declare const inlineConfig = {
"nuxt": {}
}
type ResolvedAppConfig = Defu<typeof inlineConfig, []>
type IsAny<T> = 0 extends 1 & T ? true : false
type MergedAppConfig<Resolved extends Record<string, unknown>, Custom extends Record<string, unknown>> = {
[K in keyof (Resolved & Custom)]: K extends keyof Custom
? unknown extends Custom[K]
? Resolved[K]
: IsAny<Custom[K]> extends true
? Resolved[K]
: Custom[K] extends Record<string, any>
? Resolved[K] extends Record<string, any>
? MergedAppConfig<Resolved[K], Custom[K]>
: Exclude<Custom[K], undefined>
: Exclude<Custom[K], undefined>
: Resolved[K]
}
declare module 'nuxt/schema' {
interface AppConfig extends MergedAppConfig<ResolvedAppConfig, CustomAppConfig> { }
}
declare module '@nuxt/schema' {
interface AppConfig extends MergedAppConfig<ResolvedAppConfig, CustomAppConfig> { }
}

View File

@@ -0,0 +1,24 @@
declare module "#build/app-component.mjs";
declare module "#build/nitro.client.mjs";
declare module "#build/plugins.client.mjs";
declare module "#build/css.mjs";
declare module "#build/fetch.mjs";
declare module "#build/error-component.mjs";
declare module "#build/global-polyfills.mjs";
declare module "#build/layouts.mjs";
declare module "#build/middleware.mjs";
declare module "#build/nuxt.config.mjs";
declare module "#build/paths.mjs";
declare module "#build/root-component.mjs";
declare module "#build/plugins.server.mjs";
declare module "#build/test-component-wrapper.mjs";
declare module "#build/routes.mjs";
declare module "#build/pages.mjs";
declare module "#build/router.options.mjs";
declare module "#build/unhead-options.mjs";
declare module "#build/unhead.config.mjs";
declare module "#build/components.plugin.mjs";
declare module "#build/component-names.mjs";
declare module "#build/components.islands.mjs";
declare module "#build/component-chunk.mjs";
declare module "#build/route-rules.mjs";

View File

@@ -0,0 +1 @@
import "vite/client";

View File

@@ -0,0 +1,439 @@
import type { DefineComponent, SlotsType } from 'vue'
type IslandComponent<T> = DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>> & T
type HydrationStrategies = {
hydrateOnVisible?: IntersectionObserverInit | true
hydrateOnIdle?: number | true
hydrateOnInteraction?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | true
hydrateOnMediaQuery?: string
hydrateAfter?: number
hydrateWhen?: boolean
hydrateNever?: true
}
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
interface _GlobalComponents {
AiLogsTile: typeof import("../../components/AiLogsTile.vue")['default']
FinancialTile: typeof import("../../components/FinancialTile.vue")['default']
SalespersonTile: typeof import("../../components/SalespersonTile.vue")['default']
ServiceMapTile: typeof import("../../components/ServiceMapTile.vue")['default']
SystemHealthTile: typeof import("../../components/SystemHealthTile.vue")['default']
TileCard: typeof import("../../components/TileCard.vue")['default']
TileWrapper: typeof import("../../components/TileWrapper.vue")['default']
MapServiceMap: typeof import("../../components/map/ServiceMap.vue")['default']
NuxtWelcome: typeof import("../../node_modules/nuxt/dist/app/components/welcome.vue")['default']
NuxtLayout: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-layout")['default']
NuxtErrorBoundary: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
ClientOnly: typeof import("../../node_modules/nuxt/dist/app/components/client-only")['default']
DevOnly: typeof import("../../node_modules/nuxt/dist/app/components/dev-only")['default']
ServerPlaceholder: typeof import("../../node_modules/nuxt/dist/app/components/server-placeholder")['default']
NuxtLink: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-link")['default']
NuxtLoadingIndicator: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']
NuxtTime: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']
NuxtRouteAnnouncer: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']
NuxtImg: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']
NuxtPicture: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']
VAvatar: typeof import("vuetify/components")['VAvatar']
VBanner: typeof import("vuetify/components")['VBanner']
VBannerActions: typeof import("vuetify/components")['VBannerActions']
VBannerText: typeof import("vuetify/components")['VBannerText']
VApp: typeof import("vuetify/components")['VApp']
VAppBar: typeof import("vuetify/components")['VAppBar']
VAppBarNavIcon: typeof import("vuetify/components")['VAppBarNavIcon']
VAppBarTitle: typeof import("vuetify/components")['VAppBarTitle']
VCalendar: typeof import("vuetify/components")['VCalendar']
VAlert: typeof import("vuetify/components")['VAlert']
VAlertTitle: typeof import("vuetify/components")['VAlertTitle']
VBtnToggle: typeof import("vuetify/components")['VBtnToggle']
VBreadcrumbs: typeof import("vuetify/components")['VBreadcrumbs']
VBreadcrumbsItem: typeof import("vuetify/components")['VBreadcrumbsItem']
VBreadcrumbsDivider: typeof import("vuetify/components")['VBreadcrumbsDivider']
VBtnGroup: typeof import("vuetify/components")['VBtnGroup']
VBtn: typeof import("vuetify/components")['VBtn']
VBadge: typeof import("vuetify/components")['VBadge']
VBottomNavigation: typeof import("vuetify/components")['VBottomNavigation']
VCheckbox: typeof import("vuetify/components")['VCheckbox']
VCheckboxBtn: typeof import("vuetify/components")['VCheckboxBtn']
VCarousel: typeof import("vuetify/components")['VCarousel']
VCarouselItem: typeof import("vuetify/components")['VCarouselItem']
VChip: typeof import("vuetify/components")['VChip']
VCard: typeof import("vuetify/components")['VCard']
VCardActions: typeof import("vuetify/components")['VCardActions']
VCardItem: typeof import("vuetify/components")['VCardItem']
VCardSubtitle: typeof import("vuetify/components")['VCardSubtitle']
VCardText: typeof import("vuetify/components")['VCardText']
VCardTitle: typeof import("vuetify/components")['VCardTitle']
VBottomSheet: typeof import("vuetify/components")['VBottomSheet']
VChipGroup: typeof import("vuetify/components")['VChipGroup']
VColorPicker: typeof import("vuetify/components")['VColorPicker']
VCombobox: typeof import("vuetify/components")['VCombobox']
VCode: typeof import("vuetify/components")['VCode']
VCounter: typeof import("vuetify/components")['VCounter']
VDatePicker: typeof import("vuetify/components")['VDatePicker']
VDatePickerControls: typeof import("vuetify/components")['VDatePickerControls']
VDatePickerHeader: typeof import("vuetify/components")['VDatePickerHeader']
VDatePickerMonth: typeof import("vuetify/components")['VDatePickerMonth']
VDatePickerMonths: typeof import("vuetify/components")['VDatePickerMonths']
VDatePickerYears: typeof import("vuetify/components")['VDatePickerYears']
VDialog: typeof import("vuetify/components")['VDialog']
VDivider: typeof import("vuetify/components")['VDivider']
VFab: typeof import("vuetify/components")['VFab']
VField: typeof import("vuetify/components")['VField']
VFieldLabel: typeof import("vuetify/components")['VFieldLabel']
VEmptyState: typeof import("vuetify/components")['VEmptyState']
VExpansionPanels: typeof import("vuetify/components")['VExpansionPanels']
VExpansionPanel: typeof import("vuetify/components")['VExpansionPanel']
VExpansionPanelText: typeof import("vuetify/components")['VExpansionPanelText']
VExpansionPanelTitle: typeof import("vuetify/components")['VExpansionPanelTitle']
VDataTable: typeof import("vuetify/components")['VDataTable']
VDataTableHeaders: typeof import("vuetify/components")['VDataTableHeaders']
VDataTableFooter: typeof import("vuetify/components")['VDataTableFooter']
VDataTableRows: typeof import("vuetify/components")['VDataTableRows']
VDataTableRow: typeof import("vuetify/components")['VDataTableRow']
VDataTableVirtual: typeof import("vuetify/components")['VDataTableVirtual']
VDataTableServer: typeof import("vuetify/components")['VDataTableServer']
VHotkey: typeof import("vuetify/components")['VHotkey']
VFileInput: typeof import("vuetify/components")['VFileInput']
VFooter: typeof import("vuetify/components")['VFooter']
VImg: typeof import("vuetify/components")['VImg']
VItemGroup: typeof import("vuetify/components")['VItemGroup']
VItem: typeof import("vuetify/components")['VItem']
VIcon: typeof import("vuetify/components")['VIcon']
VComponentIcon: typeof import("vuetify/components")['VComponentIcon']
VSvgIcon: typeof import("vuetify/components")['VSvgIcon']
VLigatureIcon: typeof import("vuetify/components")['VLigatureIcon']
VClassIcon: typeof import("vuetify/components")['VClassIcon']
VInput: typeof import("vuetify/components")['VInput']
VInfiniteScroll: typeof import("vuetify/components")['VInfiniteScroll']
VKbd: typeof import("vuetify/components")['VKbd']
VMenu: typeof import("vuetify/components")['VMenu']
VNavigationDrawer: typeof import("vuetify/components")['VNavigationDrawer']
VLabel: typeof import("vuetify/components")['VLabel']
VMain: typeof import("vuetify/components")['VMain']
VMessages: typeof import("vuetify/components")['VMessages']
VOverlay: typeof import("vuetify/components")['VOverlay']
VList: typeof import("vuetify/components")['VList']
VListGroup: typeof import("vuetify/components")['VListGroup']
VListImg: typeof import("vuetify/components")['VListImg']
VListItem: typeof import("vuetify/components")['VListItem']
VListItemAction: typeof import("vuetify/components")['VListItemAction']
VListItemMedia: typeof import("vuetify/components")['VListItemMedia']
VListItemSubtitle: typeof import("vuetify/components")['VListItemSubtitle']
VListItemTitle: typeof import("vuetify/components")['VListItemTitle']
VListSubheader: typeof import("vuetify/components")['VListSubheader']
VPagination: typeof import("vuetify/components")['VPagination']
VNumberInput: typeof import("vuetify/components")['VNumberInput']
VProgressLinear: typeof import("vuetify/components")['VProgressLinear']
VOtpInput: typeof import("vuetify/components")['VOtpInput']
VRadioGroup: typeof import("vuetify/components")['VRadioGroup']
VSelectionControl: typeof import("vuetify/components")['VSelectionControl']
VProgressCircular: typeof import("vuetify/components")['VProgressCircular']
VSelect: typeof import("vuetify/components")['VSelect']
VSheet: typeof import("vuetify/components")['VSheet']
VSelectionControlGroup: typeof import("vuetify/components")['VSelectionControlGroup']
VSlideGroup: typeof import("vuetify/components")['VSlideGroup']
VSlideGroupItem: typeof import("vuetify/components")['VSlideGroupItem']
VSkeletonLoader: typeof import("vuetify/components")['VSkeletonLoader']
VRating: typeof import("vuetify/components")['VRating']
VSnackbar: typeof import("vuetify/components")['VSnackbar']
VTextarea: typeof import("vuetify/components")['VTextarea']
VSystemBar: typeof import("vuetify/components")['VSystemBar']
VSwitch: typeof import("vuetify/components")['VSwitch']
VStepper: typeof import("vuetify/components")['VStepper']
VStepperActions: typeof import("vuetify/components")['VStepperActions']
VStepperHeader: typeof import("vuetify/components")['VStepperHeader']
VStepperItem: typeof import("vuetify/components")['VStepperItem']
VStepperWindow: typeof import("vuetify/components")['VStepperWindow']
VStepperWindowItem: typeof import("vuetify/components")['VStepperWindowItem']
VSlider: typeof import("vuetify/components")['VSlider']
VTab: typeof import("vuetify/components")['VTab']
VTabs: typeof import("vuetify/components")['VTabs']
VTabsWindow: typeof import("vuetify/components")['VTabsWindow']
VTabsWindowItem: typeof import("vuetify/components")['VTabsWindowItem']
VTable: typeof import("vuetify/components")['VTable']
VTimeline: typeof import("vuetify/components")['VTimeline']
VTimelineItem: typeof import("vuetify/components")['VTimelineItem']
VTextField: typeof import("vuetify/components")['VTextField']
VTooltip: typeof import("vuetify/components")['VTooltip']
VToolbar: typeof import("vuetify/components")['VToolbar']
VToolbarTitle: typeof import("vuetify/components")['VToolbarTitle']
VToolbarItems: typeof import("vuetify/components")['VToolbarItems']
VWindow: typeof import("vuetify/components")['VWindow']
VWindowItem: typeof import("vuetify/components")['VWindowItem']
VTimePicker: typeof import("vuetify/components")['VTimePicker']
VTimePickerClock: typeof import("vuetify/components")['VTimePickerClock']
VTimePickerControls: typeof import("vuetify/components")['VTimePickerControls']
VTreeview: typeof import("vuetify/components")['VTreeview']
VTreeviewItem: typeof import("vuetify/components")['VTreeviewItem']
VTreeviewGroup: typeof import("vuetify/components")['VTreeviewGroup']
VConfirmEdit: typeof import("vuetify/components")['VConfirmEdit']
VDataIterator: typeof import("vuetify/components")['VDataIterator']
VDefaultsProvider: typeof import("vuetify/components")['VDefaultsProvider']
VContainer: typeof import("vuetify/components")['VContainer']
VCol: typeof import("vuetify/components")['VCol']
VRow: typeof import("vuetify/components")['VRow']
VSpacer: typeof import("vuetify/components")['VSpacer']
VForm: typeof import("vuetify/components")['VForm']
VAutocomplete: typeof import("vuetify/components")['VAutocomplete']
VHover: typeof import("vuetify/components")['VHover']
VLazy: typeof import("vuetify/components")['VLazy']
VLayout: typeof import("vuetify/components")['VLayout']
VLayoutItem: typeof import("vuetify/components")['VLayoutItem']
VLocaleProvider: typeof import("vuetify/components")['VLocaleProvider']
VRadio: typeof import("vuetify/components")['VRadio']
VParallax: typeof import("vuetify/components")['VParallax']
VNoSsr: typeof import("vuetify/components")['VNoSsr']
VRangeSlider: typeof import("vuetify/components")['VRangeSlider']
VResponsive: typeof import("vuetify/components")['VResponsive']
VSnackbarQueue: typeof import("vuetify/components")['VSnackbarQueue']
VSpeedDial: typeof import("vuetify/components")['VSpeedDial']
VSparkline: typeof import("vuetify/components")['VSparkline']
VVirtualScroll: typeof import("vuetify/components")['VVirtualScroll']
VThemeProvider: typeof import("vuetify/components")['VThemeProvider']
VFabTransition: typeof import("vuetify/components")['VFabTransition']
VDialogBottomTransition: typeof import("vuetify/components")['VDialogBottomTransition']
VDialogTopTransition: typeof import("vuetify/components")['VDialogTopTransition']
VFadeTransition: typeof import("vuetify/components")['VFadeTransition']
VScaleTransition: typeof import("vuetify/components")['VScaleTransition']
VScrollXTransition: typeof import("vuetify/components")['VScrollXTransition']
VScrollXReverseTransition: typeof import("vuetify/components")['VScrollXReverseTransition']
VScrollYTransition: typeof import("vuetify/components")['VScrollYTransition']
VScrollYReverseTransition: typeof import("vuetify/components")['VScrollYReverseTransition']
VSlideXTransition: typeof import("vuetify/components")['VSlideXTransition']
VSlideXReverseTransition: typeof import("vuetify/components")['VSlideXReverseTransition']
VSlideYTransition: typeof import("vuetify/components")['VSlideYTransition']
VSlideYReverseTransition: typeof import("vuetify/components")['VSlideYReverseTransition']
VExpandTransition: typeof import("vuetify/components")['VExpandTransition']
VExpandXTransition: typeof import("vuetify/components")['VExpandXTransition']
VExpandBothTransition: typeof import("vuetify/components")['VExpandBothTransition']
VDialogTransition: typeof import("vuetify/components")['VDialogTransition']
VValidation: typeof import("vuetify/components")['VValidation']
NuxtLinkLocale: typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/components/NuxtLinkLocale")['default']
SwitchLocalePathLink: typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/components/SwitchLocalePathLink")['default']
NuxtPage: typeof import("../../node_modules/nuxt/dist/pages/runtime/page")['default']
NoScript: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['NoScript']
Link: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Link']
Base: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Base']
Title: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Title']
Meta: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Meta']
Style: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Style']
Head: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Head']
Html: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Html']
Body: typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Body']
NuxtIsland: typeof import("../../node_modules/nuxt/dist/app/components/nuxt-island")['default']
LazyAiLogsTile: LazyComponent<typeof import("../../components/AiLogsTile.vue")['default']>
LazyFinancialTile: LazyComponent<typeof import("../../components/FinancialTile.vue")['default']>
LazySalespersonTile: LazyComponent<typeof import("../../components/SalespersonTile.vue")['default']>
LazyServiceMapTile: LazyComponent<typeof import("../../components/ServiceMapTile.vue")['default']>
LazySystemHealthTile: LazyComponent<typeof import("../../components/SystemHealthTile.vue")['default']>
LazyTileCard: LazyComponent<typeof import("../../components/TileCard.vue")['default']>
LazyTileWrapper: LazyComponent<typeof import("../../components/TileWrapper.vue")['default']>
LazyMapServiceMap: LazyComponent<typeof import("../../components/map/ServiceMap.vue")['default']>
LazyNuxtWelcome: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/welcome.vue")['default']>
LazyNuxtLayout: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-layout")['default']>
LazyNuxtErrorBoundary: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>
LazyClientOnly: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/client-only")['default']>
LazyDevOnly: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/dev-only")['default']>
LazyServerPlaceholder: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/server-placeholder")['default']>
LazyNuxtLink: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-link")['default']>
LazyNuxtLoadingIndicator: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']>
LazyNuxtTime: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']>
LazyNuxtRouteAnnouncer: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']>
LazyNuxtImg: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']>
LazyNuxtPicture: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']>
LazyVAvatar: LazyComponent<typeof import("vuetify/components")['VAvatar']>
LazyVBanner: LazyComponent<typeof import("vuetify/components")['VBanner']>
LazyVBannerActions: LazyComponent<typeof import("vuetify/components")['VBannerActions']>
LazyVBannerText: LazyComponent<typeof import("vuetify/components")['VBannerText']>
LazyVApp: LazyComponent<typeof import("vuetify/components")['VApp']>
LazyVAppBar: LazyComponent<typeof import("vuetify/components")['VAppBar']>
LazyVAppBarNavIcon: LazyComponent<typeof import("vuetify/components")['VAppBarNavIcon']>
LazyVAppBarTitle: LazyComponent<typeof import("vuetify/components")['VAppBarTitle']>
LazyVCalendar: LazyComponent<typeof import("vuetify/components")['VCalendar']>
LazyVAlert: LazyComponent<typeof import("vuetify/components")['VAlert']>
LazyVAlertTitle: LazyComponent<typeof import("vuetify/components")['VAlertTitle']>
LazyVBtnToggle: LazyComponent<typeof import("vuetify/components")['VBtnToggle']>
LazyVBreadcrumbs: LazyComponent<typeof import("vuetify/components")['VBreadcrumbs']>
LazyVBreadcrumbsItem: LazyComponent<typeof import("vuetify/components")['VBreadcrumbsItem']>
LazyVBreadcrumbsDivider: LazyComponent<typeof import("vuetify/components")['VBreadcrumbsDivider']>
LazyVBtnGroup: LazyComponent<typeof import("vuetify/components")['VBtnGroup']>
LazyVBtn: LazyComponent<typeof import("vuetify/components")['VBtn']>
LazyVBadge: LazyComponent<typeof import("vuetify/components")['VBadge']>
LazyVBottomNavigation: LazyComponent<typeof import("vuetify/components")['VBottomNavigation']>
LazyVCheckbox: LazyComponent<typeof import("vuetify/components")['VCheckbox']>
LazyVCheckboxBtn: LazyComponent<typeof import("vuetify/components")['VCheckboxBtn']>
LazyVCarousel: LazyComponent<typeof import("vuetify/components")['VCarousel']>
LazyVCarouselItem: LazyComponent<typeof import("vuetify/components")['VCarouselItem']>
LazyVChip: LazyComponent<typeof import("vuetify/components")['VChip']>
LazyVCard: LazyComponent<typeof import("vuetify/components")['VCard']>
LazyVCardActions: LazyComponent<typeof import("vuetify/components")['VCardActions']>
LazyVCardItem: LazyComponent<typeof import("vuetify/components")['VCardItem']>
LazyVCardSubtitle: LazyComponent<typeof import("vuetify/components")['VCardSubtitle']>
LazyVCardText: LazyComponent<typeof import("vuetify/components")['VCardText']>
LazyVCardTitle: LazyComponent<typeof import("vuetify/components")['VCardTitle']>
LazyVBottomSheet: LazyComponent<typeof import("vuetify/components")['VBottomSheet']>
LazyVChipGroup: LazyComponent<typeof import("vuetify/components")['VChipGroup']>
LazyVColorPicker: LazyComponent<typeof import("vuetify/components")['VColorPicker']>
LazyVCombobox: LazyComponent<typeof import("vuetify/components")['VCombobox']>
LazyVCode: LazyComponent<typeof import("vuetify/components")['VCode']>
LazyVCounter: LazyComponent<typeof import("vuetify/components")['VCounter']>
LazyVDatePicker: LazyComponent<typeof import("vuetify/components")['VDatePicker']>
LazyVDatePickerControls: LazyComponent<typeof import("vuetify/components")['VDatePickerControls']>
LazyVDatePickerHeader: LazyComponent<typeof import("vuetify/components")['VDatePickerHeader']>
LazyVDatePickerMonth: LazyComponent<typeof import("vuetify/components")['VDatePickerMonth']>
LazyVDatePickerMonths: LazyComponent<typeof import("vuetify/components")['VDatePickerMonths']>
LazyVDatePickerYears: LazyComponent<typeof import("vuetify/components")['VDatePickerYears']>
LazyVDialog: LazyComponent<typeof import("vuetify/components")['VDialog']>
LazyVDivider: LazyComponent<typeof import("vuetify/components")['VDivider']>
LazyVFab: LazyComponent<typeof import("vuetify/components")['VFab']>
LazyVField: LazyComponent<typeof import("vuetify/components")['VField']>
LazyVFieldLabel: LazyComponent<typeof import("vuetify/components")['VFieldLabel']>
LazyVEmptyState: LazyComponent<typeof import("vuetify/components")['VEmptyState']>
LazyVExpansionPanels: LazyComponent<typeof import("vuetify/components")['VExpansionPanels']>
LazyVExpansionPanel: LazyComponent<typeof import("vuetify/components")['VExpansionPanel']>
LazyVExpansionPanelText: LazyComponent<typeof import("vuetify/components")['VExpansionPanelText']>
LazyVExpansionPanelTitle: LazyComponent<typeof import("vuetify/components")['VExpansionPanelTitle']>
LazyVDataTable: LazyComponent<typeof import("vuetify/components")['VDataTable']>
LazyVDataTableHeaders: LazyComponent<typeof import("vuetify/components")['VDataTableHeaders']>
LazyVDataTableFooter: LazyComponent<typeof import("vuetify/components")['VDataTableFooter']>
LazyVDataTableRows: LazyComponent<typeof import("vuetify/components")['VDataTableRows']>
LazyVDataTableRow: LazyComponent<typeof import("vuetify/components")['VDataTableRow']>
LazyVDataTableVirtual: LazyComponent<typeof import("vuetify/components")['VDataTableVirtual']>
LazyVDataTableServer: LazyComponent<typeof import("vuetify/components")['VDataTableServer']>
LazyVHotkey: LazyComponent<typeof import("vuetify/components")['VHotkey']>
LazyVFileInput: LazyComponent<typeof import("vuetify/components")['VFileInput']>
LazyVFooter: LazyComponent<typeof import("vuetify/components")['VFooter']>
LazyVImg: LazyComponent<typeof import("vuetify/components")['VImg']>
LazyVItemGroup: LazyComponent<typeof import("vuetify/components")['VItemGroup']>
LazyVItem: LazyComponent<typeof import("vuetify/components")['VItem']>
LazyVIcon: LazyComponent<typeof import("vuetify/components")['VIcon']>
LazyVComponentIcon: LazyComponent<typeof import("vuetify/components")['VComponentIcon']>
LazyVSvgIcon: LazyComponent<typeof import("vuetify/components")['VSvgIcon']>
LazyVLigatureIcon: LazyComponent<typeof import("vuetify/components")['VLigatureIcon']>
LazyVClassIcon: LazyComponent<typeof import("vuetify/components")['VClassIcon']>
LazyVInput: LazyComponent<typeof import("vuetify/components")['VInput']>
LazyVInfiniteScroll: LazyComponent<typeof import("vuetify/components")['VInfiniteScroll']>
LazyVKbd: LazyComponent<typeof import("vuetify/components")['VKbd']>
LazyVMenu: LazyComponent<typeof import("vuetify/components")['VMenu']>
LazyVNavigationDrawer: LazyComponent<typeof import("vuetify/components")['VNavigationDrawer']>
LazyVLabel: LazyComponent<typeof import("vuetify/components")['VLabel']>
LazyVMain: LazyComponent<typeof import("vuetify/components")['VMain']>
LazyVMessages: LazyComponent<typeof import("vuetify/components")['VMessages']>
LazyVOverlay: LazyComponent<typeof import("vuetify/components")['VOverlay']>
LazyVList: LazyComponent<typeof import("vuetify/components")['VList']>
LazyVListGroup: LazyComponent<typeof import("vuetify/components")['VListGroup']>
LazyVListImg: LazyComponent<typeof import("vuetify/components")['VListImg']>
LazyVListItem: LazyComponent<typeof import("vuetify/components")['VListItem']>
LazyVListItemAction: LazyComponent<typeof import("vuetify/components")['VListItemAction']>
LazyVListItemMedia: LazyComponent<typeof import("vuetify/components")['VListItemMedia']>
LazyVListItemSubtitle: LazyComponent<typeof import("vuetify/components")['VListItemSubtitle']>
LazyVListItemTitle: LazyComponent<typeof import("vuetify/components")['VListItemTitle']>
LazyVListSubheader: LazyComponent<typeof import("vuetify/components")['VListSubheader']>
LazyVPagination: LazyComponent<typeof import("vuetify/components")['VPagination']>
LazyVNumberInput: LazyComponent<typeof import("vuetify/components")['VNumberInput']>
LazyVProgressLinear: LazyComponent<typeof import("vuetify/components")['VProgressLinear']>
LazyVOtpInput: LazyComponent<typeof import("vuetify/components")['VOtpInput']>
LazyVRadioGroup: LazyComponent<typeof import("vuetify/components")['VRadioGroup']>
LazyVSelectionControl: LazyComponent<typeof import("vuetify/components")['VSelectionControl']>
LazyVProgressCircular: LazyComponent<typeof import("vuetify/components")['VProgressCircular']>
LazyVSelect: LazyComponent<typeof import("vuetify/components")['VSelect']>
LazyVSheet: LazyComponent<typeof import("vuetify/components")['VSheet']>
LazyVSelectionControlGroup: LazyComponent<typeof import("vuetify/components")['VSelectionControlGroup']>
LazyVSlideGroup: LazyComponent<typeof import("vuetify/components")['VSlideGroup']>
LazyVSlideGroupItem: LazyComponent<typeof import("vuetify/components")['VSlideGroupItem']>
LazyVSkeletonLoader: LazyComponent<typeof import("vuetify/components")['VSkeletonLoader']>
LazyVRating: LazyComponent<typeof import("vuetify/components")['VRating']>
LazyVSnackbar: LazyComponent<typeof import("vuetify/components")['VSnackbar']>
LazyVTextarea: LazyComponent<typeof import("vuetify/components")['VTextarea']>
LazyVSystemBar: LazyComponent<typeof import("vuetify/components")['VSystemBar']>
LazyVSwitch: LazyComponent<typeof import("vuetify/components")['VSwitch']>
LazyVStepper: LazyComponent<typeof import("vuetify/components")['VStepper']>
LazyVStepperActions: LazyComponent<typeof import("vuetify/components")['VStepperActions']>
LazyVStepperHeader: LazyComponent<typeof import("vuetify/components")['VStepperHeader']>
LazyVStepperItem: LazyComponent<typeof import("vuetify/components")['VStepperItem']>
LazyVStepperWindow: LazyComponent<typeof import("vuetify/components")['VStepperWindow']>
LazyVStepperWindowItem: LazyComponent<typeof import("vuetify/components")['VStepperWindowItem']>
LazyVSlider: LazyComponent<typeof import("vuetify/components")['VSlider']>
LazyVTab: LazyComponent<typeof import("vuetify/components")['VTab']>
LazyVTabs: LazyComponent<typeof import("vuetify/components")['VTabs']>
LazyVTabsWindow: LazyComponent<typeof import("vuetify/components")['VTabsWindow']>
LazyVTabsWindowItem: LazyComponent<typeof import("vuetify/components")['VTabsWindowItem']>
LazyVTable: LazyComponent<typeof import("vuetify/components")['VTable']>
LazyVTimeline: LazyComponent<typeof import("vuetify/components")['VTimeline']>
LazyVTimelineItem: LazyComponent<typeof import("vuetify/components")['VTimelineItem']>
LazyVTextField: LazyComponent<typeof import("vuetify/components")['VTextField']>
LazyVTooltip: LazyComponent<typeof import("vuetify/components")['VTooltip']>
LazyVToolbar: LazyComponent<typeof import("vuetify/components")['VToolbar']>
LazyVToolbarTitle: LazyComponent<typeof import("vuetify/components")['VToolbarTitle']>
LazyVToolbarItems: LazyComponent<typeof import("vuetify/components")['VToolbarItems']>
LazyVWindow: LazyComponent<typeof import("vuetify/components")['VWindow']>
LazyVWindowItem: LazyComponent<typeof import("vuetify/components")['VWindowItem']>
LazyVTimePicker: LazyComponent<typeof import("vuetify/components")['VTimePicker']>
LazyVTimePickerClock: LazyComponent<typeof import("vuetify/components")['VTimePickerClock']>
LazyVTimePickerControls: LazyComponent<typeof import("vuetify/components")['VTimePickerControls']>
LazyVTreeview: LazyComponent<typeof import("vuetify/components")['VTreeview']>
LazyVTreeviewItem: LazyComponent<typeof import("vuetify/components")['VTreeviewItem']>
LazyVTreeviewGroup: LazyComponent<typeof import("vuetify/components")['VTreeviewGroup']>
LazyVConfirmEdit: LazyComponent<typeof import("vuetify/components")['VConfirmEdit']>
LazyVDataIterator: LazyComponent<typeof import("vuetify/components")['VDataIterator']>
LazyVDefaultsProvider: LazyComponent<typeof import("vuetify/components")['VDefaultsProvider']>
LazyVContainer: LazyComponent<typeof import("vuetify/components")['VContainer']>
LazyVCol: LazyComponent<typeof import("vuetify/components")['VCol']>
LazyVRow: LazyComponent<typeof import("vuetify/components")['VRow']>
LazyVSpacer: LazyComponent<typeof import("vuetify/components")['VSpacer']>
LazyVForm: LazyComponent<typeof import("vuetify/components")['VForm']>
LazyVAutocomplete: LazyComponent<typeof import("vuetify/components")['VAutocomplete']>
LazyVHover: LazyComponent<typeof import("vuetify/components")['VHover']>
LazyVLazy: LazyComponent<typeof import("vuetify/components")['VLazy']>
LazyVLayout: LazyComponent<typeof import("vuetify/components")['VLayout']>
LazyVLayoutItem: LazyComponent<typeof import("vuetify/components")['VLayoutItem']>
LazyVLocaleProvider: LazyComponent<typeof import("vuetify/components")['VLocaleProvider']>
LazyVRadio: LazyComponent<typeof import("vuetify/components")['VRadio']>
LazyVParallax: LazyComponent<typeof import("vuetify/components")['VParallax']>
LazyVNoSsr: LazyComponent<typeof import("vuetify/components")['VNoSsr']>
LazyVRangeSlider: LazyComponent<typeof import("vuetify/components")['VRangeSlider']>
LazyVResponsive: LazyComponent<typeof import("vuetify/components")['VResponsive']>
LazyVSnackbarQueue: LazyComponent<typeof import("vuetify/components")['VSnackbarQueue']>
LazyVSpeedDial: LazyComponent<typeof import("vuetify/components")['VSpeedDial']>
LazyVSparkline: LazyComponent<typeof import("vuetify/components")['VSparkline']>
LazyVVirtualScroll: LazyComponent<typeof import("vuetify/components")['VVirtualScroll']>
LazyVThemeProvider: LazyComponent<typeof import("vuetify/components")['VThemeProvider']>
LazyVFabTransition: LazyComponent<typeof import("vuetify/components")['VFabTransition']>
LazyVDialogBottomTransition: LazyComponent<typeof import("vuetify/components")['VDialogBottomTransition']>
LazyVDialogTopTransition: LazyComponent<typeof import("vuetify/components")['VDialogTopTransition']>
LazyVFadeTransition: LazyComponent<typeof import("vuetify/components")['VFadeTransition']>
LazyVScaleTransition: LazyComponent<typeof import("vuetify/components")['VScaleTransition']>
LazyVScrollXTransition: LazyComponent<typeof import("vuetify/components")['VScrollXTransition']>
LazyVScrollXReverseTransition: LazyComponent<typeof import("vuetify/components")['VScrollXReverseTransition']>
LazyVScrollYTransition: LazyComponent<typeof import("vuetify/components")['VScrollYTransition']>
LazyVScrollYReverseTransition: LazyComponent<typeof import("vuetify/components")['VScrollYReverseTransition']>
LazyVSlideXTransition: LazyComponent<typeof import("vuetify/components")['VSlideXTransition']>
LazyVSlideXReverseTransition: LazyComponent<typeof import("vuetify/components")['VSlideXReverseTransition']>
LazyVSlideYTransition: LazyComponent<typeof import("vuetify/components")['VSlideYTransition']>
LazyVSlideYReverseTransition: LazyComponent<typeof import("vuetify/components")['VSlideYReverseTransition']>
LazyVExpandTransition: LazyComponent<typeof import("vuetify/components")['VExpandTransition']>
LazyVExpandXTransition: LazyComponent<typeof import("vuetify/components")['VExpandXTransition']>
LazyVExpandBothTransition: LazyComponent<typeof import("vuetify/components")['VExpandBothTransition']>
LazyVDialogTransition: LazyComponent<typeof import("vuetify/components")['VDialogTransition']>
LazyVValidation: LazyComponent<typeof import("vuetify/components")['VValidation']>
LazyNuxtLinkLocale: LazyComponent<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/components/NuxtLinkLocale")['default']>
LazySwitchLocalePathLink: LazyComponent<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/components/SwitchLocalePathLink")['default']>
LazyNuxtPage: LazyComponent<typeof import("../../node_modules/nuxt/dist/pages/runtime/page")['default']>
LazyNoScript: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['NoScript']>
LazyLink: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Link']>
LazyBase: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Base']>
LazyTitle: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Title']>
LazyMeta: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Meta']>
LazyStyle: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Style']>
LazyHead: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Head']>
LazyHtml: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Html']>
LazyBody: LazyComponent<typeof import("../../node_modules/nuxt/dist/head/runtime/components")['Body']>
LazyNuxtIsland: LazyComponent<typeof import("../../node_modules/nuxt/dist/app/components/nuxt-island")['default']>
}
declare module 'vue' {
export interface GlobalComponents extends _GlobalComponents { }
}
export {}

View File

@@ -0,0 +1,20 @@
// Generated by @nuxtjs/i18n
import type { ExportedGlobalComposer, Composer } from 'vue-i18n'
import type { NuxtI18nRoutingCustomProperties, ComposerCustomProperties } from '../../node_modules/@nuxtjs/i18n/dist/runtime/types.ts'
import type { Strategies, Directions, LocaleObject } from '../../node_modules/@nuxtjs/i18n/dist/types.d.ts'
declare module 'vue-i18n' {
interface ComposerCustom extends ComposerCustomProperties<LocaleObject[]> {}
interface ExportedGlobalComposer extends NuxtI18nRoutingCustomProperties<LocaleObject[]> {}
interface VueI18n extends NuxtI18nRoutingCustomProperties<LocaleObject[]> {}
}
declare module '#app' {
interface NuxtApp {
$i18n: ExportedGlobalComposer & Composer & NuxtI18nRoutingCustomProperties<LocaleObject[]>
}
}
export {}

View File

@@ -0,0 +1,449 @@
// Generated by auto imports
export {}
declare global {
const AdminTiles: typeof import('../../composables/useRBAC').AdminTiles
const Role: typeof import('../../composables/useRBAC').Role
const RoleRank: typeof import('../../composables/useRBAC').RoleRank
const ScopeLevel: typeof import('../../composables/useRBAC').ScopeLevel
const abortNavigation: typeof import('../../node_modules/nuxt/dist/app/composables/router').abortNavigation
const acceptHMRUpdate: typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables').acceptHMRUpdate
const addRouteMiddleware: typeof import('../../node_modules/nuxt/dist/app/composables/router').addRouteMiddleware
const callOnce: typeof import('../../node_modules/nuxt/dist/app/composables/once').callOnce
const cancelIdleCallback: typeof import('../../node_modules/nuxt/dist/app/compat/idle-callback').cancelIdleCallback
const clearError: typeof import('../../node_modules/nuxt/dist/app/composables/error').clearError
const clearNuxtData: typeof import('../../node_modules/nuxt/dist/app/composables/asyncData').clearNuxtData
const clearNuxtState: typeof import('../../node_modules/nuxt/dist/app/composables/state').clearNuxtState
const computed: typeof import('vue').computed
const createError: typeof import('../../node_modules/nuxt/dist/app/composables/error').createError
const customRef: typeof import('vue').customRef
const defineAppConfig: typeof import('../../node_modules/nuxt/dist/app/nuxt').defineAppConfig
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineI18nConfig: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').defineI18nConfig
const defineI18nLocale: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').defineI18nLocale
const defineI18nRoute: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').defineI18nRoute
const defineLazyHydrationComponent: typeof import('../../node_modules/nuxt/dist/app/composables/lazy-hydration').defineLazyHydrationComponent
const defineNuxtComponent: typeof import('../../node_modules/nuxt/dist/app/composables/component').defineNuxtComponent
const defineNuxtLink: typeof import('../../node_modules/nuxt/dist/app/components/nuxt-link').defineNuxtLink
const defineNuxtPlugin: typeof import('../../node_modules/nuxt/dist/app/nuxt').defineNuxtPlugin
const defineNuxtRouteMiddleware: typeof import('../../node_modules/nuxt/dist/app/composables/router').defineNuxtRouteMiddleware
const definePageMeta: typeof import('../../node_modules/nuxt/dist/pages/runtime/composables').definePageMeta
const definePayloadPlugin: typeof import('../../node_modules/nuxt/dist/app/nuxt').definePayloadPlugin
const definePayloadReducer: typeof import('../../node_modules/nuxt/dist/app/composables/payload').definePayloadReducer
const definePayloadReviver: typeof import('../../node_modules/nuxt/dist/app/composables/payload').definePayloadReviver
const defineStore: typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables').defineStore
const effect: typeof import('vue').effect
const effectScope: typeof import('vue').effectScope
const getAppManifest: typeof import('../../node_modules/nuxt/dist/app/composables/manifest').getAppManifest
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getRouteRules: typeof import('../../node_modules/nuxt/dist/app/composables/manifest').getRouteRules
const h: typeof import('vue').h
const hasInjectionContext: typeof import('vue').hasInjectionContext
const inject: typeof import('vue').inject
const injectHead: typeof import('../../node_modules/nuxt/dist/app/composables/head').injectHead
const isNuxtError: typeof import('../../node_modules/nuxt/dist/app/composables/error').isNuxtError
const isPrerendered: typeof import('../../node_modules/nuxt/dist/app/composables/payload').isPrerendered
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const isVue2: typeof import('../../node_modules/nuxt/dist/app/compat/vue-demi').isVue2
const isVue3: typeof import('../../node_modules/nuxt/dist/app/compat/vue-demi').isVue3
const loadPayload: typeof import('../../node_modules/nuxt/dist/app/composables/payload').loadPayload
const markRaw: typeof import('vue').markRaw
const navigateTo: typeof import('../../node_modules/nuxt/dist/app/composables/router').navigateTo
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onNuxtReady: typeof import('../../node_modules/nuxt/dist/app/composables/ready').onNuxtReady
const onPrehydrate: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').onPrehydrate
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const prefetchComponents: typeof import('../../node_modules/nuxt/dist/app/composables/preload').prefetchComponents
const preloadComponents: typeof import('../../node_modules/nuxt/dist/app/composables/preload').preloadComponents
const preloadPayload: typeof import('../../node_modules/nuxt/dist/app/composables/payload').preloadPayload
const preloadRouteComponents: typeof import('../../node_modules/nuxt/dist/app/composables/preload').preloadRouteComponents
const prerenderRoutes: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').prerenderRoutes
const provide: typeof import('vue').provide
const proxyRefs: typeof import('vue').proxyRefs
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const refreshCookie: typeof import('../../node_modules/nuxt/dist/app/composables/cookie').refreshCookie
const refreshNuxtData: typeof import('../../node_modules/nuxt/dist/app/composables/asyncData').refreshNuxtData
const reloadNuxtApp: typeof import('../../node_modules/nuxt/dist/app/composables/chunk').reloadNuxtApp
const requestIdleCallback: typeof import('../../node_modules/nuxt/dist/app/compat/idle-callback').requestIdleCallback
const resolveComponent: typeof import('vue').resolveComponent
const setInterval: typeof import('../../node_modules/nuxt/dist/app/compat/interval').setInterval
const setPageLayout: typeof import('../../node_modules/nuxt/dist/app/composables/router').setPageLayout
const setResponseStatus: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').setResponseStatus
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const showError: typeof import('../../node_modules/nuxt/dist/app/composables/error').showError
const storeToRefs: typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const tryUseNuxtApp: typeof import('../../node_modules/nuxt/dist/app/nuxt').tryUseNuxtApp
const unref: typeof import('vue').unref
const updateAppConfig: typeof import('../../node_modules/nuxt/dist/app/config').updateAppConfig
const useAppConfig: typeof import('../../node_modules/nuxt/dist/app/config').useAppConfig
const useAsyncData: typeof import('../../node_modules/nuxt/dist/app/composables/asyncData').useAsyncData
const useAttrs: typeof import('vue').useAttrs
const useAuthStore: typeof import('../../stores/auth').useAuthStore
const useBrowserLocale: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useBrowserLocale
const useCookie: typeof import('../../node_modules/nuxt/dist/app/composables/cookie').useCookie
const useCookieLocale: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useCookieLocale
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useDefaults: typeof import('vuetify').useDefaults
const useDisplay: typeof import('vuetify').useDisplay
const useError: typeof import('../../node_modules/nuxt/dist/app/composables/error').useError
const useFetch: typeof import('../../node_modules/nuxt/dist/app/composables/fetch').useFetch
const useHead: typeof import('../../node_modules/nuxt/dist/app/composables/head').useHead
const useHeadSafe: typeof import('../../node_modules/nuxt/dist/app/composables/head').useHeadSafe
const useHealthMonitor: typeof import('../../composables/useHealthMonitor').useHealthMonitor
const useHydration: typeof import('../../node_modules/nuxt/dist/app/composables/hydrate').useHydration
const useI18n: typeof import('../../node_modules/vue-i18n/dist/vue-i18n').useI18n
const useId: typeof import('vue').useId
const useLayout: typeof import('vuetify').useLayout
const useLazyAsyncData: typeof import('../../node_modules/nuxt/dist/app/composables/asyncData').useLazyAsyncData
const useLazyFetch: typeof import('../../node_modules/nuxt/dist/app/composables/fetch').useLazyFetch
const useLink: typeof import('vue-router').useLink
const useLoadingIndicator: typeof import('../../node_modules/nuxt/dist/app/composables/loading-indicator').useLoadingIndicator
const useLocale: typeof import('vuetify').useLocale
const useLocaleHead: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useLocaleHead
const useLocalePath: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useLocalePath
const useLocaleRoute: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useLocaleRoute
const useModel: typeof import('vue').useModel
const useNuxtApp: typeof import('../../node_modules/nuxt/dist/app/nuxt').useNuxtApp
const useNuxtData: typeof import('../../node_modules/nuxt/dist/app/composables/asyncData').useNuxtData
const usePinia: typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables').usePinia
const usePolling: typeof import('../../composables/usePolling').default
const usePreviewMode: typeof import('../../node_modules/nuxt/dist/app/composables/preview').usePreviewMode
const useRBAC: typeof import('../../composables/useRBAC').useRBAC
const useRequestEvent: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').useRequestEvent
const useRequestFetch: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').useRequestFetch
const useRequestHeader: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').useRequestHeader
const useRequestHeaders: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').useRequestHeaders
const useRequestURL: typeof import('../../node_modules/nuxt/dist/app/composables/url').useRequestURL
const useResponseHeader: typeof import('../../node_modules/nuxt/dist/app/composables/ssr').useResponseHeader
const useRoute: typeof import('../../node_modules/nuxt/dist/app/composables/router').useRoute
const useRouteAnnouncer: typeof import('../../node_modules/nuxt/dist/app/composables/route-announcer').useRouteAnnouncer
const useRouteBaseName: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useRouteBaseName
const useRouter: typeof import('../../node_modules/nuxt/dist/app/composables/router').useRouter
const useRtl: typeof import('vuetify').useRtl
const useRuntimeConfig: typeof import('../../node_modules/nuxt/dist/app/nuxt').useRuntimeConfig
const useRuntimeHook: typeof import('../../node_modules/nuxt/dist/app/composables/runtime-hook').useRuntimeHook
const useScript: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScript
const useScriptClarity: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptClarity
const useScriptCloudflareWebAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptCloudflareWebAnalytics
const useScriptCrisp: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptCrisp
const useScriptDatabuddyAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptDatabuddyAnalytics
const useScriptEventPage: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptEventPage
const useScriptFathomAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptFathomAnalytics
const useScriptGoogleAdsense: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptGoogleAdsense
const useScriptGoogleAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptGoogleAnalytics
const useScriptGoogleMaps: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptGoogleMaps
const useScriptGoogleTagManager: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptGoogleTagManager
const useScriptHotjar: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptHotjar
const useScriptIntercom: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptIntercom
const useScriptLemonSqueezy: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptLemonSqueezy
const useScriptMatomoAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptMatomoAnalytics
const useScriptMetaPixel: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptMetaPixel
const useScriptNpm: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptNpm
const useScriptPayPal: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptPayPal
const useScriptPlausibleAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptPlausibleAnalytics
const useScriptRedditPixel: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptRedditPixel
const useScriptRybbitAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptRybbitAnalytics
const useScriptSegment: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptSegment
const useScriptSnapchatPixel: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptSnapchatPixel
const useScriptStripe: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptStripe
const useScriptTriggerConsent: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptTriggerConsent
const useScriptTriggerElement: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptTriggerElement
const useScriptUmamiAnalytics: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptUmamiAnalytics
const useScriptVimeoPlayer: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptVimeoPlayer
const useScriptXPixel: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptXPixel
const useScriptYouTubePlayer: typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs').useScriptYouTubePlayer
const useSeoMeta: typeof import('../../node_modules/nuxt/dist/app/composables/head').useSeoMeta
const useServerHead: typeof import('../../node_modules/nuxt/dist/app/composables/head').useServerHead
const useServerHeadSafe: typeof import('../../node_modules/nuxt/dist/app/composables/head').useServerHeadSafe
const useServerSeoMeta: typeof import('../../node_modules/nuxt/dist/app/composables/head').useServerSeoMeta
const useServiceMap: typeof import('../../composables/useServiceMap').useServiceMap
const useSetI18nParams: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useSetI18nParams
const useShadowRoot: typeof import('vue').useShadowRoot
const useSlots: typeof import('vue').useSlots
const useState: typeof import('../../node_modules/nuxt/dist/app/composables/state').useState
const useSwitchLocalePath: typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index').useSwitchLocalePath
const useTemplateRef: typeof import('vue').useTemplateRef
const useTheme: typeof import('vuetify').useTheme
const useTileStore: typeof import('../../stores/tiles').useTileStore
const useTransitionState: typeof import('vue').useTransitionState
const useUserManagement: typeof import('../../composables/useUserManagement').default
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
const withCtx: typeof import('vue').withCtx
const withDirectives: typeof import('vue').withDirectives
const withKeys: typeof import('vue').withKeys
const withMemo: typeof import('vue').withMemo
const withModifiers: typeof import('vue').withModifiers
const withScopeId: typeof import('vue').withScopeId
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { HealthMetrics, SystemAlert, HealthMonitorState } from '../../composables/useHealthMonitor'
import('../../composables/useHealthMonitor')
// @ts-ignore
export type { PollingOptions, PollingState } from '../../composables/usePolling'
import('../../composables/usePolling')
// @ts-ignore
export type { Role, ScopeLevel, TilePermission } from '../../composables/useRBAC'
import('../../composables/useRBAC')
// @ts-ignore
export type { Service, Scope } from '../../composables/useServiceMap'
import('../../composables/useServiceMap')
// @ts-ignore
export type { UpdateUserRoleRequest, UserManagementState } from '../../composables/useUserManagement'
import('../../composables/useUserManagement')
// @ts-ignore
export type { JwtPayload, User } from '../../stores/auth'
import('../../stores/auth')
// @ts-ignore
export type { UserTilePreference } from '../../stores/tiles'
import('../../stores/tiles')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface ComponentCustomProperties {
readonly AdminTiles: UnwrapRef<typeof import('../../composables/useRBAC')['AdminTiles']>
readonly Role: UnwrapRef<typeof import('../../composables/useRBAC')['Role']>
readonly RoleRank: UnwrapRef<typeof import('../../composables/useRBAC')['RoleRank']>
readonly ScopeLevel: UnwrapRef<typeof import('../../composables/useRBAC')['ScopeLevel']>
readonly abortNavigation: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['abortNavigation']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables')['acceptHMRUpdate']>
readonly addRouteMiddleware: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['addRouteMiddleware']>
readonly callOnce: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/once')['callOnce']>
readonly cancelIdleCallback: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/compat/idle-callback')['cancelIdleCallback']>
readonly clearError: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/error')['clearError']>
readonly clearNuxtData: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/asyncData')['clearNuxtData']>
readonly clearNuxtState: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/state')['clearNuxtState']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createError: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/error')['createError']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAppConfig: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/nuxt')['defineAppConfig']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineI18nConfig: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['defineI18nConfig']>
readonly defineI18nLocale: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['defineI18nLocale']>
readonly defineI18nRoute: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['defineI18nRoute']>
readonly defineLazyHydrationComponent: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/lazy-hydration')['defineLazyHydrationComponent']>
readonly defineNuxtComponent: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/component')['defineNuxtComponent']>
readonly defineNuxtLink: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/components/nuxt-link')['defineNuxtLink']>
readonly defineNuxtPlugin: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/nuxt')['defineNuxtPlugin']>
readonly defineNuxtRouteMiddleware: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['defineNuxtRouteMiddleware']>
readonly definePageMeta: UnwrapRef<typeof import('../../node_modules/nuxt/dist/pages/runtime/composables')['definePageMeta']>
readonly definePayloadPlugin: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/nuxt')['definePayloadPlugin']>
readonly definePayloadReducer: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/payload')['definePayloadReducer']>
readonly definePayloadReviver: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/payload')['definePayloadReviver']>
readonly defineStore: UnwrapRef<typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables')['defineStore']>
readonly effect: UnwrapRef<typeof import('vue')['effect']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getAppManifest: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/manifest')['getAppManifest']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getRouteRules: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/manifest')['getRouteRules']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hasInjectionContext: UnwrapRef<typeof import('vue')['hasInjectionContext']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectHead: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['injectHead']>
readonly isNuxtError: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/error')['isNuxtError']>
readonly isPrerendered: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/payload')['isPrerendered']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
readonly isVue2: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/compat/vue-demi')['isVue2']>
readonly isVue3: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/compat/vue-demi')['isVue3']>
readonly loadPayload: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/payload')['loadPayload']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly navigateTo: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['navigateTo']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onNuxtReady: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ready')['onNuxtReady']>
readonly onPrehydrate: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['onPrehydrate']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly prefetchComponents: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/preload')['prefetchComponents']>
readonly preloadComponents: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/preload')['preloadComponents']>
readonly preloadPayload: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/payload')['preloadPayload']>
readonly preloadRouteComponents: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/preload')['preloadRouteComponents']>
readonly prerenderRoutes: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['prerenderRoutes']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly proxyRefs: UnwrapRef<typeof import('vue')['proxyRefs']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly refreshCookie: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/cookie')['refreshCookie']>
readonly refreshNuxtData: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/asyncData')['refreshNuxtData']>
readonly reloadNuxtApp: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/chunk')['reloadNuxtApp']>
readonly requestIdleCallback: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/compat/idle-callback')['requestIdleCallback']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly setInterval: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/compat/interval')['setInterval']>
readonly setPageLayout: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['setPageLayout']>
readonly setResponseStatus: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['setResponseStatus']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly showError: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/error')['showError']>
readonly storeToRefs: UnwrapRef<typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables')['storeToRefs']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryUseNuxtApp: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/nuxt')['tryUseNuxtApp']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly updateAppConfig: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/config')['updateAppConfig']>
readonly useAppConfig: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/config')['useAppConfig']>
readonly useAsyncData: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/asyncData')['useAsyncData']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAuthStore: UnwrapRef<typeof import('../../stores/auth')['useAuthStore']>
readonly useBrowserLocale: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useBrowserLocale']>
readonly useCookie: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/cookie')['useCookie']>
readonly useCookieLocale: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useCookieLocale']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useDefaults: UnwrapRef<typeof import('vuetify')['useDefaults']>
readonly useDisplay: UnwrapRef<typeof import('vuetify')['useDisplay']>
readonly useError: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/error')['useError']>
readonly useFetch: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/fetch')['useFetch']>
readonly useHead: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['useHead']>
readonly useHeadSafe: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['useHeadSafe']>
readonly useHealthMonitor: UnwrapRef<typeof import('../../composables/useHealthMonitor')['useHealthMonitor']>
readonly useHydration: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/hydrate')['useHydration']>
readonly useI18n: UnwrapRef<typeof import('../../node_modules/vue-i18n/dist/vue-i18n')['useI18n']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useLayout: UnwrapRef<typeof import('vuetify')['useLayout']>
readonly useLazyAsyncData: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/asyncData')['useLazyAsyncData']>
readonly useLazyFetch: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/fetch')['useLazyFetch']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLoadingIndicator: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/loading-indicator')['useLoadingIndicator']>
readonly useLocale: UnwrapRef<typeof import('vuetify')['useLocale']>
readonly useLocaleHead: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useLocaleHead']>
readonly useLocalePath: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useLocalePath']>
readonly useLocaleRoute: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useLocaleRoute']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useNuxtApp: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/nuxt')['useNuxtApp']>
readonly useNuxtData: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/asyncData')['useNuxtData']>
readonly usePinia: UnwrapRef<typeof import('../../node_modules/@pinia/nuxt/dist/runtime/composables')['usePinia']>
readonly usePolling: UnwrapRef<typeof import('../../composables/usePolling')['default']>
readonly usePreviewMode: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/preview')['usePreviewMode']>
readonly useRBAC: UnwrapRef<typeof import('../../composables/useRBAC')['useRBAC']>
readonly useRequestEvent: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['useRequestEvent']>
readonly useRequestFetch: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['useRequestFetch']>
readonly useRequestHeader: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['useRequestHeader']>
readonly useRequestHeaders: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['useRequestHeaders']>
readonly useRequestURL: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/url')['useRequestURL']>
readonly useResponseHeader: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/ssr')['useResponseHeader']>
readonly useRoute: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['useRoute']>
readonly useRouteAnnouncer: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/route-announcer')['useRouteAnnouncer']>
readonly useRouteBaseName: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useRouteBaseName']>
readonly useRouter: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/router')['useRouter']>
readonly useRtl: UnwrapRef<typeof import('vuetify')['useRtl']>
readonly useRuntimeConfig: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/nuxt')['useRuntimeConfig']>
readonly useRuntimeHook: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/runtime-hook')['useRuntimeHook']>
readonly useScript: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScript']>
readonly useScriptClarity: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptClarity']>
readonly useScriptCloudflareWebAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptCloudflareWebAnalytics']>
readonly useScriptCrisp: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptCrisp']>
readonly useScriptDatabuddyAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptDatabuddyAnalytics']>
readonly useScriptEventPage: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptEventPage']>
readonly useScriptFathomAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptFathomAnalytics']>
readonly useScriptGoogleAdsense: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptGoogleAdsense']>
readonly useScriptGoogleAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptGoogleAnalytics']>
readonly useScriptGoogleMaps: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptGoogleMaps']>
readonly useScriptGoogleTagManager: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptGoogleTagManager']>
readonly useScriptHotjar: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptHotjar']>
readonly useScriptIntercom: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptIntercom']>
readonly useScriptLemonSqueezy: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptLemonSqueezy']>
readonly useScriptMatomoAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptMatomoAnalytics']>
readonly useScriptMetaPixel: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptMetaPixel']>
readonly useScriptNpm: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptNpm']>
readonly useScriptPayPal: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptPayPal']>
readonly useScriptPlausibleAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptPlausibleAnalytics']>
readonly useScriptRedditPixel: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptRedditPixel']>
readonly useScriptRybbitAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptRybbitAnalytics']>
readonly useScriptSegment: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptSegment']>
readonly useScriptSnapchatPixel: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptSnapchatPixel']>
readonly useScriptStripe: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptStripe']>
readonly useScriptTriggerConsent: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptTriggerConsent']>
readonly useScriptTriggerElement: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptTriggerElement']>
readonly useScriptUmamiAnalytics: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptUmamiAnalytics']>
readonly useScriptVimeoPlayer: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptVimeoPlayer']>
readonly useScriptXPixel: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptXPixel']>
readonly useScriptYouTubePlayer: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/script-stubs')['useScriptYouTubePlayer']>
readonly useSeoMeta: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['useSeoMeta']>
readonly useServerHead: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['useServerHead']>
readonly useServerHeadSafe: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['useServerHeadSafe']>
readonly useServerSeoMeta: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/head')['useServerSeoMeta']>
readonly useServiceMap: UnwrapRef<typeof import('../../composables/useServiceMap')['useServiceMap']>
readonly useSetI18nParams: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useSetI18nParams']>
readonly useShadowRoot: UnwrapRef<typeof import('vue')['useShadowRoot']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useState: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/state')['useState']>
readonly useSwitchLocalePath: UnwrapRef<typeof import('../../node_modules/@nuxtjs/i18n/dist/runtime/composables/index')['useSwitchLocalePath']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTheme: UnwrapRef<typeof import('vuetify')['useTheme']>
readonly useTileStore: UnwrapRef<typeof import('../../stores/tiles')['useTileStore']>
readonly useTransitionState: UnwrapRef<typeof import('vue')['useTransitionState']>
readonly useUserManagement: UnwrapRef<typeof import('../../composables/useUserManagement')['default']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
readonly withCtx: UnwrapRef<typeof import('vue')['withCtx']>
readonly withDirectives: UnwrapRef<typeof import('vue')['withDirectives']>
readonly withKeys: UnwrapRef<typeof import('vue')['withKeys']>
readonly withMemo: UnwrapRef<typeof import('vue')['withMemo']>
readonly withModifiers: UnwrapRef<typeof import('vue')['withModifiers']>
readonly withScopeId: UnwrapRef<typeof import('vue')['withScopeId']>
}
}

View File

@@ -0,0 +1,14 @@
import type { ComputedRef, MaybeRef } from 'vue'
type ComponentProps<T> = T extends new(...args: any) => { $props: infer P } ? NonNullable<P>
: T extends (props: infer P, ...args: any) => any ? P
: {}
declare module 'nuxt/app' {
interface NuxtLayouts {
}
export type LayoutKey = keyof NuxtLayouts extends never ? string : keyof NuxtLayouts
interface PageMeta {
layout?: MaybeRef<LayoutKey | false> | ComputedRef<LayoutKey | false>
}
}

View File

@@ -0,0 +1,7 @@
import type { NavigationGuard } from 'vue-router'
export type MiddlewareKey = never
declare module 'nuxt/app' {
interface PageMeta {
middleware?: MiddlewareKey | NavigationGuard | Array<MiddlewareKey | NavigationGuard>
}
}

View File

@@ -0,0 +1,14 @@
// Generated by nitro
// App Config
import type { Defu } from 'defu'
type UserAppConfig = Defu<{}, []>
declare module "nitropack/types" {
interface AppConfig extends UserAppConfig {}
}
export {}

View File

@@ -0,0 +1,149 @@
declare global {
const H3Error: typeof import('../../node_modules/h3').H3Error
const H3Event: typeof import('../../node_modules/h3').H3Event
const __buildAssetsURL: typeof import('../../node_modules/@nuxt/nitro-server/dist/runtime/utils/paths').buildAssetsURL
const __publicAssetsURL: typeof import('../../node_modules/@nuxt/nitro-server/dist/runtime/utils/paths').publicAssetsURL
const appendCorsHeaders: typeof import('../../node_modules/h3').appendCorsHeaders
const appendCorsPreflightHeaders: typeof import('../../node_modules/h3').appendCorsPreflightHeaders
const appendHeader: typeof import('../../node_modules/h3').appendHeader
const appendHeaders: typeof import('../../node_modules/h3').appendHeaders
const appendResponseHeader: typeof import('../../node_modules/h3').appendResponseHeader
const appendResponseHeaders: typeof import('../../node_modules/h3').appendResponseHeaders
const assertMethod: typeof import('../../node_modules/h3').assertMethod
const cachedEventHandler: typeof import('../../node_modules/nitropack/dist/runtime/internal/cache').cachedEventHandler
const cachedFunction: typeof import('../../node_modules/nitropack/dist/runtime/internal/cache').cachedFunction
const callNodeListener: typeof import('../../node_modules/h3').callNodeListener
const clearResponseHeaders: typeof import('../../node_modules/h3').clearResponseHeaders
const clearSession: typeof import('../../node_modules/h3').clearSession
const createApp: typeof import('../../node_modules/h3').createApp
const createAppEventHandler: typeof import('../../node_modules/h3').createAppEventHandler
const createError: typeof import('../../node_modules/h3').createError
const createEvent: typeof import('../../node_modules/h3').createEvent
const createEventStream: typeof import('../../node_modules/h3').createEventStream
const createRouter: typeof import('../../node_modules/h3').createRouter
const defaultContentType: typeof import('../../node_modules/h3').defaultContentType
const defineAppConfig: typeof import('../../node_modules/@nuxt/nitro-server/dist/runtime/utils/config').defineAppConfig
const defineCachedEventHandler: typeof import('../../node_modules/nitropack/dist/runtime/internal/cache').defineCachedEventHandler
const defineCachedFunction: typeof import('../../node_modules/nitropack/dist/runtime/internal/cache').defineCachedFunction
const defineEventHandler: typeof import('../../node_modules/h3').defineEventHandler
const defineLazyEventHandler: typeof import('../../node_modules/h3').defineLazyEventHandler
const defineNitroErrorHandler: typeof import('../../node_modules/nitropack/dist/runtime/internal/error/utils').defineNitroErrorHandler
const defineNitroPlugin: typeof import('../../node_modules/nitropack/dist/runtime/internal/plugin').defineNitroPlugin
const defineNodeListener: typeof import('../../node_modules/h3').defineNodeListener
const defineNodeMiddleware: typeof import('../../node_modules/h3').defineNodeMiddleware
const defineRenderHandler: typeof import('../../node_modules/nitropack/dist/runtime/internal/renderer').defineRenderHandler
const defineRequestMiddleware: typeof import('../../node_modules/h3').defineRequestMiddleware
const defineResponseMiddleware: typeof import('../../node_modules/h3').defineResponseMiddleware
const defineRouteMeta: typeof import('../../node_modules/nitropack/dist/runtime/internal/meta').defineRouteMeta
const defineTask: typeof import('../../node_modules/nitropack/dist/runtime/internal/task').defineTask
const defineWebSocket: typeof import('../../node_modules/h3').defineWebSocket
const defineWebSocketHandler: typeof import('../../node_modules/h3').defineWebSocketHandler
const deleteCookie: typeof import('../../node_modules/h3').deleteCookie
const dynamicEventHandler: typeof import('../../node_modules/h3').dynamicEventHandler
const eventHandler: typeof import('../../node_modules/h3').eventHandler
const fetchWithEvent: typeof import('../../node_modules/h3').fetchWithEvent
const fromNodeMiddleware: typeof import('../../node_modules/h3').fromNodeMiddleware
const fromPlainHandler: typeof import('../../node_modules/h3').fromPlainHandler
const fromWebHandler: typeof import('../../node_modules/h3').fromWebHandler
const getCookie: typeof import('../../node_modules/h3').getCookie
const getHeader: typeof import('../../node_modules/h3').getHeader
const getHeaders: typeof import('../../node_modules/h3').getHeaders
const getMethod: typeof import('../../node_modules/h3').getMethod
const getProxyRequestHeaders: typeof import('../../node_modules/h3').getProxyRequestHeaders
const getQuery: typeof import('../../node_modules/h3').getQuery
const getRequestFingerprint: typeof import('../../node_modules/h3').getRequestFingerprint
const getRequestHeader: typeof import('../../node_modules/h3').getRequestHeader
const getRequestHeaders: typeof import('../../node_modules/h3').getRequestHeaders
const getRequestHost: typeof import('../../node_modules/h3').getRequestHost
const getRequestIP: typeof import('../../node_modules/h3').getRequestIP
const getRequestPath: typeof import('../../node_modules/h3').getRequestPath
const getRequestProtocol: typeof import('../../node_modules/h3').getRequestProtocol
const getRequestURL: typeof import('../../node_modules/h3').getRequestURL
const getRequestWebStream: typeof import('../../node_modules/h3').getRequestWebStream
const getResponseHeader: typeof import('../../node_modules/h3').getResponseHeader
const getResponseHeaders: typeof import('../../node_modules/h3').getResponseHeaders
const getResponseStatus: typeof import('../../node_modules/h3').getResponseStatus
const getResponseStatusText: typeof import('../../node_modules/h3').getResponseStatusText
const getRouteRules: typeof import('../../node_modules/nitropack/dist/runtime/internal/route-rules').getRouteRules
const getRouterParam: typeof import('../../node_modules/h3').getRouterParam
const getRouterParams: typeof import('../../node_modules/h3').getRouterParams
const getSession: typeof import('../../node_modules/h3').getSession
const getValidatedQuery: typeof import('../../node_modules/h3').getValidatedQuery
const getValidatedRouterParams: typeof import('../../node_modules/h3').getValidatedRouterParams
const handleCacheHeaders: typeof import('../../node_modules/h3').handleCacheHeaders
const handleCors: typeof import('../../node_modules/h3').handleCors
const isCorsOriginAllowed: typeof import('../../node_modules/h3').isCorsOriginAllowed
const isError: typeof import('../../node_modules/h3').isError
const isEvent: typeof import('../../node_modules/h3').isEvent
const isEventHandler: typeof import('../../node_modules/h3').isEventHandler
const isMethod: typeof import('../../node_modules/h3').isMethod
const isPreflightRequest: typeof import('../../node_modules/h3').isPreflightRequest
const isStream: typeof import('../../node_modules/h3').isStream
const isWebResponse: typeof import('../../node_modules/h3').isWebResponse
const lazyEventHandler: typeof import('../../node_modules/h3').lazyEventHandler
const nitroPlugin: typeof import('../../node_modules/nitropack/dist/runtime/internal/plugin').nitroPlugin
const parseCookies: typeof import('../../node_modules/h3').parseCookies
const promisifyNodeListener: typeof import('../../node_modules/h3').promisifyNodeListener
const proxyRequest: typeof import('../../node_modules/h3').proxyRequest
const readBody: typeof import('../../node_modules/h3').readBody
const readFormData: typeof import('../../node_modules/h3').readFormData
const readMultipartFormData: typeof import('../../node_modules/h3').readMultipartFormData
const readRawBody: typeof import('../../node_modules/h3').readRawBody
const readValidatedBody: typeof import('../../node_modules/h3').readValidatedBody
const removeResponseHeader: typeof import('../../node_modules/h3').removeResponseHeader
const runTask: typeof import('../../node_modules/nitropack/dist/runtime/internal/task').runTask
const sanitizeStatusCode: typeof import('../../node_modules/h3').sanitizeStatusCode
const sanitizeStatusMessage: typeof import('../../node_modules/h3').sanitizeStatusMessage
const sealSession: typeof import('../../node_modules/h3').sealSession
const send: typeof import('../../node_modules/h3').send
const sendError: typeof import('../../node_modules/h3').sendError
const sendIterable: typeof import('../../node_modules/h3').sendIterable
const sendNoContent: typeof import('../../node_modules/h3').sendNoContent
const sendProxy: typeof import('../../node_modules/h3').sendProxy
const sendRedirect: typeof import('../../node_modules/h3').sendRedirect
const sendStream: typeof import('../../node_modules/h3').sendStream
const sendWebResponse: typeof import('../../node_modules/h3').sendWebResponse
const serveStatic: typeof import('../../node_modules/h3').serveStatic
const setCookie: typeof import('../../node_modules/h3').setCookie
const setHeader: typeof import('../../node_modules/h3').setHeader
const setHeaders: typeof import('../../node_modules/h3').setHeaders
const setResponseHeader: typeof import('../../node_modules/h3').setResponseHeader
const setResponseHeaders: typeof import('../../node_modules/h3').setResponseHeaders
const setResponseStatus: typeof import('../../node_modules/h3').setResponseStatus
const splitCookiesString: typeof import('../../node_modules/h3').splitCookiesString
const toEventHandler: typeof import('../../node_modules/h3').toEventHandler
const toNodeListener: typeof import('../../node_modules/h3').toNodeListener
const toPlainHandler: typeof import('../../node_modules/h3').toPlainHandler
const toWebHandler: typeof import('../../node_modules/h3').toWebHandler
const toWebRequest: typeof import('../../node_modules/h3').toWebRequest
const unsealSession: typeof import('../../node_modules/h3').unsealSession
const updateSession: typeof import('../../node_modules/h3').updateSession
const useAppConfig: typeof import('../../node_modules/nitropack/dist/runtime/internal/config').useAppConfig
const useBase: typeof import('../../node_modules/h3').useBase
const useEvent: typeof import('../../node_modules/nitropack/dist/runtime/internal/context').useEvent
const useNitroApp: typeof import('../../node_modules/nitropack/dist/runtime/internal/app').useNitroApp
const useRuntimeConfig: typeof import('../../node_modules/nitropack/dist/runtime/internal/config').useRuntimeConfig
const useSession: typeof import('../../node_modules/h3').useSession
const useStorage: typeof import('../../node_modules/nitropack/dist/runtime/internal/storage').useStorage
const writeEarlyHints: typeof import('../../node_modules/h3').writeEarlyHints
}
// for type re-export
declare global {
// @ts-ignore
export type { EventHandler, EventHandlerRequest, EventHandlerResponse, EventHandlerObject, H3EventContext } from '../../node_modules/h3'
import('../../node_modules/h3')
}
export { H3Event, H3Error, appendCorsHeaders, appendCorsPreflightHeaders, appendHeader, appendHeaders, appendResponseHeader, appendResponseHeaders, assertMethod, callNodeListener, clearResponseHeaders, clearSession, createApp, createAppEventHandler, createError, createEvent, createEventStream, createRouter, defaultContentType, defineEventHandler, defineLazyEventHandler, defineNodeListener, defineNodeMiddleware, defineRequestMiddleware, defineResponseMiddleware, defineWebSocket, defineWebSocketHandler, deleteCookie, dynamicEventHandler, eventHandler, fetchWithEvent, fromNodeMiddleware, fromPlainHandler, fromWebHandler, getCookie, getHeader, getHeaders, getMethod, getProxyRequestHeaders, getQuery, getRequestFingerprint, getRequestHeader, getRequestHeaders, getRequestHost, getRequestIP, getRequestPath, getRequestProtocol, getRequestURL, getRequestWebStream, getResponseHeader, getResponseHeaders, getResponseStatus, getResponseStatusText, getRouterParam, getRouterParams, getSession, getValidatedQuery, getValidatedRouterParams, handleCacheHeaders, handleCors, isCorsOriginAllowed, isError, isEvent, isEventHandler, isMethod, isPreflightRequest, isStream, isWebResponse, lazyEventHandler, parseCookies, promisifyNodeListener, proxyRequest, readBody, readFormData, readMultipartFormData, readRawBody, readValidatedBody, removeResponseHeader, sanitizeStatusCode, sanitizeStatusMessage, sealSession, send, sendError, sendIterable, sendNoContent, sendProxy, sendRedirect, sendStream, sendWebResponse, serveStatic, setCookie, setHeader, setHeaders, setResponseHeader, setResponseHeaders, setResponseStatus, splitCookiesString, toEventHandler, toNodeListener, toPlainHandler, toWebHandler, toWebRequest, unsealSession, updateSession, useBase, useSession, writeEarlyHints } from 'h3';
export { useNitroApp } from 'nitropack/runtime/internal/app';
export { useRuntimeConfig, useAppConfig } from 'nitropack/runtime/internal/config';
export { defineNitroPlugin, nitroPlugin } from 'nitropack/runtime/internal/plugin';
export { defineCachedFunction, defineCachedEventHandler, cachedFunction, cachedEventHandler } from 'nitropack/runtime/internal/cache';
export { useStorage } from 'nitropack/runtime/internal/storage';
export { defineRenderHandler } from 'nitropack/runtime/internal/renderer';
export { defineRouteMeta } from 'nitropack/runtime/internal/meta';
export { getRouteRules } from 'nitropack/runtime/internal/route-rules';
export { useEvent } from 'nitropack/runtime/internal/context';
export { defineTask, runTask } from 'nitropack/runtime/internal/task';
export { defineNitroErrorHandler } from 'nitropack/runtime/internal/error/utils';
export { buildAssetsURL as __buildAssetsURL, publicAssetsURL as __publicAssetsURL } from '/app/node_modules/@nuxt/nitro-server/dist/runtime/utils/paths';
export { defineAppConfig } from '/app/node_modules/@nuxt/nitro-server/dist/runtime/utils/config';

View File

@@ -0,0 +1,17 @@
export type LayoutKey = string
declare module 'nitropack' {
interface NitroRouteConfig {
appLayout?: LayoutKey | false
}
interface NitroRouteRules {
appLayout?: LayoutKey | false
}
}
declare module 'nitropack/types' {
interface NitroRouteConfig {
appLayout?: LayoutKey | false
}
interface NitroRouteRules {
appLayout?: LayoutKey | false
}
}

View File

@@ -0,0 +1,17 @@
export type MiddlewareKey = never
declare module 'nitropack' {
interface NitroRouteConfig {
appMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>
}
interface NitroRouteRules {
appMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>
}
}
declare module 'nitropack/types' {
interface NitroRouteConfig {
appMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>
}
interface NitroRouteRules {
appMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>
}
}

View File

@@ -0,0 +1,39 @@
/// <reference path="nitro-layouts.d.ts" />
/// <reference path="app.config.d.ts" />
/// <reference path="runtime-config.d.ts" />
/// <reference path="../../node_modules/@nuxt/nitro-server/dist/index.d.mts" />
/// <reference path="nitro-middleware.d.ts" />
/// <reference path="./schema.d.ts" />
import type { RuntimeConfig } from 'nuxt/schema'
import type { H3Event } from 'h3'
import type { LogObject } from 'consola'
import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from 'nuxt/app'
declare module 'nitropack' {
interface NitroRuntimeConfigApp {
buildAssetsDir: string
cdnURL: string
}
interface NitroRuntimeConfig extends RuntimeConfig {}
interface NitroRouteConfig {
ssr?: boolean
noScripts?: boolean
/** @deprecated Use `noScripts` instead */
experimentalNoScripts?: boolean
}
interface NitroRouteRules {
ssr?: boolean
noScripts?: boolean
/** @deprecated Use `noScripts` instead */
experimentalNoScripts?: boolean
appMiddleware?: Record<string, boolean>
appLayout?: string | false
}
interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
}
}

View File

@@ -0,0 +1,14 @@
// Generated by nitro
import type { Serialize, Simplify } from "nitropack/types";
declare module "nitropack/types" {
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T
interface InternalApi {
'/__nuxt_error': {
'default': Simplify<Serialize<Awaited<ReturnType<typeof import('../../node_modules/@nuxt/nitro-server/dist/runtime/handlers/renderer').default>>>>
}
'/__nuxt_island/**': {
'default': Simplify<Serialize<Awaited<ReturnType<typeof import('../../server/#internal/nuxt/island-renderer').default>>>>
}
}
}
export {}

View File

@@ -0,0 +1,3 @@
/// <reference path="./nitro-routes.d.ts" />
/// <reference path="./nitro-config.d.ts" />
/// <reference path="./nitro-imports.d.ts" />

View File

@@ -0,0 +1,42 @@
// Generated by Nuxt'
import type { Plugin } from '#app'
type Decorate<T extends Record<string, any>> = { [K in keyof T as K extends string ? `$${K}` : never]: T[K] }
type InjectionType<A extends Plugin> = A extends {default: Plugin<infer T>} ? Decorate<T> : unknown
type NuxtAppInjections =
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/revive-payload.client.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/head/runtime/plugins/unhead.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/pages/runtime/plugins/router.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/browser-devtools-timing.client.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/payload.client.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/dev-server-logs.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/navigation-repaint.client.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/check-outdated-build.client.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/revive-payload.server.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/chunk-reload.client.js")> &
InjectionType<typeof import("../../node_modules/@pinia/nuxt/dist/runtime/plugin.vue3.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/pages/runtime/plugins/prefetch.client.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/pages/runtime/plugins/check-if-page-unused.js")> &
InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/switch-locale-path-ssr.js")> &
InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/i18n.js")> &
InjectionType<typeof import("../../node_modules/vuetify-nuxt-module/dist/runtime/plugins/vuetify-i18n.js")> &
InjectionType<typeof import("../../node_modules/vuetify-nuxt-module/dist/runtime/plugins/vuetify-icons.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/warn.dev.server.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/check-if-layout-used.js")> &
InjectionType<typeof import("../../node_modules/vuetify-nuxt-module/dist/runtime/plugins/vuetify-sync.js")>
declare module '#app' {
interface NuxtApp extends NuxtAppInjections { }
interface NuxtAppLiterals {
pluginName: 'nuxt:revive-payload:client' | 'nuxt:head' | 'nuxt:router' | 'nuxt:browser-devtools-timing' | 'nuxt:payload' | 'nuxt:revive-payload:server' | 'nuxt:chunk-reload' | 'pinia' | 'nuxt:global-components' | 'nuxt:prefetch' | 'nuxt:checkIfPageUnused' | 'i18n:plugin:switch-locale-path-ssr' | 'i18n:plugin' | 'nuxt:checkIfLayoutUsed' | 'vuetify:configuration:plugin'
}
}
declare module 'vue' {
interface ComponentCustomProperties extends NuxtAppInjections { }
}
export { }

View File

@@ -0,0 +1,217 @@
import { RuntimeConfig as UserRuntimeConfig, PublicRuntimeConfig as UserPublicRuntimeConfig } from 'nuxt/schema'
import { NuxtModule, ModuleDependencyMeta } from '@nuxt/schema'
interface SharedRuntimeConfig {
app: {
buildId: string,
baseURL: string,
buildAssetsDir: string,
cdnURL: string,
},
nitro: {
envPrefix: string,
},
}
interface SharedPublicRuntimeConfig {
apiBaseUrl: string,
appName: string,
appVersion: string,
i18n: {
baseUrl: string,
defaultLocale: string,
defaultDirection: string,
strategy: string,
lazy: boolean,
rootRedirect: any,
routesNameSeparator: string,
defaultLocaleRouteNameSuffix: string,
skipSettingLocaleOnNavigate: boolean,
differentDomains: boolean,
trailingSlash: boolean,
configLocales: Array<{
}>,
locales: {
en: {
domain: any,
},
hu: {
domain: any,
},
},
detectBrowserLanguage: {
alwaysRedirect: boolean,
cookieCrossOrigin: boolean,
cookieDomain: any,
cookieKey: string,
cookieSecure: boolean,
fallbackLocale: string,
redirectOn: string,
useCookie: boolean,
},
experimental: {
localeDetector: string,
switchLocalePathLinkSSR: boolean,
autoImportTranslationFunctions: boolean,
},
multiDomainLocales: boolean,
},
}
declare module '@nuxt/schema' {
interface ModuleDependencies {
["pinia"]?: ModuleDependencyMeta<typeof import("@pinia/nuxt").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["@nuxtjs/tailwindcss"]?: ModuleDependencyMeta<typeof import("@nuxtjs/tailwindcss").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["vuetify-nuxt-module"]?: ModuleDependencyMeta<typeof import("vuetify-nuxt-module").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["@nuxtjs/i18n"]?: ModuleDependencyMeta<typeof import("@nuxtjs/i18n").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["@nuxt/telemetry"]?: ModuleDependencyMeta<typeof import("@nuxt/telemetry").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
}
interface NuxtOptions {
/**
* Configuration for `@pinia/nuxt`
*/
["pinia"]: typeof import("@pinia/nuxt").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/tailwindcss`
*/
["tailwindcss"]: typeof import("@nuxtjs/tailwindcss").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `vuetify-nuxt-module`
*/
["vuetify"]: typeof import("vuetify-nuxt-module").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/i18n`
*/
["i18n"]: typeof import("@nuxtjs/i18n").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `@nuxt/telemetry`
*/
["telemetry"]: typeof import("@nuxt/telemetry").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
}
interface NuxtConfig {
/**
* Configuration for `@pinia/nuxt`
*/
["pinia"]?: typeof import("@pinia/nuxt").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/tailwindcss`
*/
["tailwindcss"]?: typeof import("@nuxtjs/tailwindcss").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `vuetify-nuxt-module`
*/
["vuetify"]?: typeof import("vuetify-nuxt-module").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/i18n`
*/
["i18n"]?: typeof import("@nuxtjs/i18n").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `@nuxt/telemetry`
*/
["telemetry"]?: typeof import("@nuxt/telemetry").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
modules?: (undefined | null | false | NuxtModule<any> | string | [NuxtModule | string, Record<string, any>] | ["@pinia/nuxt", Exclude<NuxtConfig["pinia"], boolean>] | ["@nuxtjs/tailwindcss", Exclude<NuxtConfig["tailwindcss"], boolean>] | ["vuetify-nuxt-module", Exclude<NuxtConfig["vuetify"], boolean>] | ["@nuxtjs/i18n", Exclude<NuxtConfig["i18n"], boolean>] | ["@nuxt/telemetry", Exclude<NuxtConfig["telemetry"], boolean>])[],
}
interface RuntimeConfig extends UserRuntimeConfig {}
interface PublicRuntimeConfig extends UserPublicRuntimeConfig {}
}
declare module 'nuxt/schema' {
interface ModuleDependencies {
["pinia"]?: ModuleDependencyMeta<typeof import("@pinia/nuxt").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["@nuxtjs/tailwindcss"]?: ModuleDependencyMeta<typeof import("@nuxtjs/tailwindcss").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["vuetify-nuxt-module"]?: ModuleDependencyMeta<typeof import("vuetify-nuxt-module").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["@nuxtjs/i18n"]?: ModuleDependencyMeta<typeof import("@nuxtjs/i18n").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
["@nuxt/telemetry"]?: ModuleDependencyMeta<typeof import("@nuxt/telemetry").default extends NuxtModule<infer O> ? O | false : Record<string, unknown>> | false
}
interface NuxtOptions {
/**
* Configuration for `@pinia/nuxt`
* @see https://www.npmjs.com/package/@pinia/nuxt
*/
["pinia"]: typeof import("@pinia/nuxt").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/tailwindcss`
* @see https://www.npmjs.com/package/@nuxtjs/tailwindcss
*/
["tailwindcss"]: typeof import("@nuxtjs/tailwindcss").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `vuetify-nuxt-module`
* @see https://www.npmjs.com/package/vuetify-nuxt-module
*/
["vuetify"]: typeof import("vuetify-nuxt-module").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/i18n`
* @see https://www.npmjs.com/package/@nuxtjs/i18n
*/
["i18n"]: typeof import("@nuxtjs/i18n").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
/**
* Configuration for `@nuxt/telemetry`
* @see https://www.npmjs.com/package/@nuxt/telemetry
*/
["telemetry"]: typeof import("@nuxt/telemetry").default extends NuxtModule<infer O, unknown, boolean> ? O | false : Record<string, any> | false
}
interface NuxtConfig {
/**
* Configuration for `@pinia/nuxt`
* @see https://www.npmjs.com/package/@pinia/nuxt
*/
["pinia"]?: typeof import("@pinia/nuxt").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/tailwindcss`
* @see https://www.npmjs.com/package/@nuxtjs/tailwindcss
*/
["tailwindcss"]?: typeof import("@nuxtjs/tailwindcss").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `vuetify-nuxt-module`
* @see https://www.npmjs.com/package/vuetify-nuxt-module
*/
["vuetify"]?: typeof import("vuetify-nuxt-module").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `@nuxtjs/i18n`
* @see https://www.npmjs.com/package/@nuxtjs/i18n
*/
["i18n"]?: typeof import("@nuxtjs/i18n").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
/**
* Configuration for `@nuxt/telemetry`
* @see https://www.npmjs.com/package/@nuxt/telemetry
*/
["telemetry"]?: typeof import("@nuxt/telemetry").default extends NuxtModule<infer O, unknown, boolean> ? Partial<O> | false : Record<string, any> | false
modules?: (undefined | null | false | NuxtModule<any> | string | [NuxtModule | string, Record<string, any>] | ["@pinia/nuxt", Exclude<NuxtConfig["pinia"], boolean>] | ["@nuxtjs/tailwindcss", Exclude<NuxtConfig["tailwindcss"], boolean>] | ["vuetify-nuxt-module", Exclude<NuxtConfig["vuetify"], boolean>] | ["@nuxtjs/i18n", Exclude<NuxtConfig["i18n"], boolean>] | ["@nuxt/telemetry", Exclude<NuxtConfig["telemetry"], boolean>])[],
}
interface RuntimeConfig extends SharedRuntimeConfig {}
interface PublicRuntimeConfig extends SharedPublicRuntimeConfig {}
}
declare module 'vue' {
interface ComponentCustomProperties {
$config: UserRuntimeConfig
}
}

View File

@@ -0,0 +1,44 @@
# Multi-stage build for Nuxt 3 admin frontend
FROM node:20-slim as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY nuxt.config.ts ./
COPY tsconfig.json ./
# Install dependencies
RUN npm install --no-audit --progress=false
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-slim as runner
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nuxtuser
# Copy built application and dependencies
COPY --from=builder --chown=nuxtuser:nodejs /app/.output ./
COPY --from=builder --chown=nuxtuser:nodejs /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Switch to non-root user
USER nuxtuser
# Expose port 3000 (Nuxt default)
EXPOSE 3000
# Start the application
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
CMD ["node", "./server/index.mjs"]

View File

@@ -0,0 +1,24 @@
# Development Dockerfile for Nuxt 3 admin frontend
FROM node:20-slim
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY nuxt.config.ts ./
COPY tsconfig.json ./
# Install dependencies
RUN npm install --no-audit --progress=false
# Copy source code
COPY . .
# Expose Nuxt development port
EXPOSE 8502
# Start development server
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=8502
CMD ["npm", "run", "dev", "--", "-o"]

View File

@@ -0,0 +1,18 @@
<template>
<div>
<NuxtPage />
</div>
</template>
<script setup lang="ts">
// Root app component
</script>
<style>
/* Global styles */
html, body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}
</style>

View File

@@ -0,0 +1,635 @@
<template>
<v-card
color="indigo-darken-1"
variant="tonal"
class="h-100 d-flex flex-column"
>
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon icon="mdi-robot" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">AI Pipeline Monitor</span>
</div>
<div class="d-flex align-center">
<v-chip size="small" color="green" class="mr-2">
<v-icon icon="mdi-pulse" size="small" class="mr-1"></v-icon>
Live
</v-chip>
<v-chip size="small" :color="connectionStatusColor">
<v-icon :icon="connectionStatusIcon" size="small" class="mr-1"></v-icon>
{{ connectionStatusText }}
</v-chip>
</div>
</v-card-title>
<v-card-text class="flex-grow-1 pa-0">
<!-- Connection Status Bar -->
<div class="px-4 pt-2 pb-1" :class="connectionStatusBarClass">
<div class="d-flex align-center justify-space-between">
<div class="text-caption">
<v-icon :icon="connectionStatusIcon" size="small" class="mr-1"></v-icon>
{{ connectionStatusMessage }}
</div>
<div class="text-caption">
Polling: {{ pollingInterval / 1000 }}s
<v-btn
icon="mdi-refresh"
size="x-small"
variant="text"
class="ml-1"
@click="forceRefresh"
:loading="isRefreshing"
></v-btn>
</div>
</div>
</div>
<!-- Robot Status Dashboard -->
<div class="pa-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Robot Status Dashboard</div>
<!-- Geographical Filter -->
<div class="mb-3">
<v-chip-group v-model="selectedRegion" column>
<v-chip size="small" value="all">All Regions</v-chip>
<v-chip size="small" value="GB">UK (GB)</v-chip>
<v-chip size="small" value="EU">Europe</v-chip>
<v-chip size="small" value="US">North America</v-chip>
<v-chip size="small" value="OC">Oceania</v-chip>
</v-chip-group>
</div>
<!-- Robot Status Cards -->
<v-row dense class="mb-4">
<v-col v-for="robot in filteredRobots" :key="robot.id" cols="12" sm="6">
<v-card variant="outlined" class="pa-2">
<div class="d-flex align-center justify-space-between">
<div>
<div class="d-flex align-center">
<v-icon :icon="robot.icon" size="small" :color="robot.statusColor" class="mr-2"></v-icon>
<span class="text-caption font-weight-medium">{{ robot.name }}</span>
</div>
<div class="text-caption text-grey mt-1">{{ robot.description }}</div>
</div>
<div class="text-right">
<div class="text-caption" :class="`text-${robot.statusColor}`">{{ robot.status }}</div>
<div class="text-caption text-grey">{{ robot.region }}</div>
</div>
</div>
<!-- Progress Bar -->
<v-progress-linear
v-if="robot.progress !== undefined"
:model-value="robot.progress"
height="6"
:color="robot.progressColor"
class="mt-2"
></v-progress-linear>
<!-- Stats -->
<div class="d-flex justify-space-between mt-2">
<div class="text-caption">
<v-icon icon="mdi-check-circle" size="x-small" color="success" class="mr-1"></v-icon>
{{ robot.successRate }}%
</div>
<div class="text-caption">
<v-icon icon="mdi-alert-circle" size="x-small" color="error" class="mr-1"></v-icon>
{{ robot.failureRate }}%
</div>
<div class="text-caption">
<v-icon icon="mdi-clock-outline" size="x-small" color="warning" class="mr-1"></v-icon>
{{ robot.avgTime }}s
</div>
</div>
</v-card>
</v-col>
</v-row>
<!-- Overall Pipeline Stats -->
<v-card variant="outlined" class="pa-3 mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Pipeline Overview</div>
<v-row dense>
<v-col cols="6" sm="3">
<div class="text-center">
<div class="text-h5 font-weight-bold text-primary">{{ pipelineStats.totalProcessed }}</div>
<div class="text-caption text-grey">Total Processed</div>
</div>
</v-col>
<v-col cols="6" sm="3">
<div class="text-center">
<div class="text-h5 font-weight-bold text-success">{{ pipelineStats.successRate }}%</div>
<div class="text-caption text-grey">Success Rate</div>
</div>
</v-col>
<v-col cols="6" sm="3">
<div class="text-center">
<div class="text-h5 font-weight-bold text-warning">{{ pipelineStats.avgProcessingTime }}s</div>
<div class="text-caption text-grey">Avg Time</div>
</div>
</v-col>
<v-col cols="6" sm="3">
<div class="text-center">
<div class="text-h5 font-weight-bold" :class="pipelineStats.queueSize > 100 ? 'text-error' : 'text-info'">{{ pipelineStats.queueSize }}</div>
<div class="text-caption text-grey">Queue Size</div>
</div>
</v-col>
</v-row>
</v-card>
<!-- Recent Activity -->
<div class="text-subtitle-2 font-weight-medium mb-2">Recent Activity</div>
<div class="log-entries-container pa-2" ref="logContainer" style="height: 150px;">
<!-- Loading State -->
<div v-if="isLoading && logs.length === 0" class="text-center py-4">
<v-progress-circular indeterminate color="primary" size="20"></v-progress-circular>
<div class="text-caption mt-1">Loading AI logs...</div>
</div>
<!-- Empty State -->
<div v-else-if="logs.length === 0" class="text-center py-4">
<v-icon icon="mdi-robot-off" size="32" color="grey-lighten-1"></v-icon>
<div class="text-body-2 mt-1">No AI activity</div>
</div>
<!-- Log Entries -->
<div v-else class="log-entries">
<div
v-for="(log, index) in visibleLogs"
:key="log.id"
class="log-entry mb-2 pa-2"
:class="{ 'new-entry': log.isNew }"
>
<div class="d-flex align-center">
<v-icon
:color="getLogColor(log.type)"
:icon="getLogIcon(log.type)"
size="small"
class="mr-2"
></v-icon>
<div class="flex-grow-1">
<div class="text-caption">{{ log.message }}</div>
<div class="d-flex align-center mt-1">
<v-chip size="x-small" :color="getRobotColor(log.robot)" class="mr-1">
{{ log.robot }}
</v-chip>
<span class="text-caption text-grey">{{ formatTime(log.timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-3">
<div class="d-flex justify-space-between align-center w-100">
<div class="text-caption">
<v-icon icon="mdi-robot" size="small" class="mr-1"></v-icon>
{{ activeRobots }} active {{ filteredRobots.length }} filtered
</div>
<div class="d-flex">
<v-btn
size="x-small"
variant="text"
@click="toggleAutoScroll"
:color="autoScroll ? 'primary' : 'grey'"
>
<v-icon :icon="autoScroll ? 'mdi-pin' : 'mdi-pin-off'" size="small" class="mr-1"></v-icon>
{{ autoScroll ? 'Auto-scroll' : 'Manual' }}
</v-btn>
</div>
</div>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
// Types
interface AiLogEntry {
id: string
timestamp: Date
message: string
type: 'info' | 'success' | 'warning' | 'error' | 'gold'
robot: string
vehicleId?: string
status?: string
details?: string
isNew?: boolean
}
interface RobotStatus {
id: string
name: string
description: string
region: string
status: 'running' | 'idle' | 'error' | 'paused'
statusColor: string
icon: string
progress?: number
progressColor: string
successRate: number
failureRate: number
avgTime: number
lastActivity: Date
}
// State
const logs = ref<AiLogEntry[]>([])
const isLoading = ref(true)
const isRefreshing = ref(false)
const autoScroll = ref(true)
const pollingInterval = ref(5000) // 5 seconds
const connectionStatus = ref<'connected' | 'disconnected' | 'error'>('connected')
const logContainer = ref<HTMLElement | null>(null)
const selectedRegion = ref('all')
// Robot status data
const robots = ref<RobotStatus[]>([
{
id: 'gb-discovery',
name: 'GB Discovery',
description: 'UK catalog discovery from MOT CSV',
region: 'GB',
status: 'running',
statusColor: 'success',
icon: 'mdi-magnify',
progress: 75,
progressColor: 'primary',
successRate: 92,
failureRate: 3,
avgTime: 45,
lastActivity: new Date()
},
{
id: 'gb-hunter',
name: 'GB Hunter',
description: 'DVLA API vehicle data fetcher',
region: 'GB',
status: 'running',
statusColor: 'success',
icon: 'mdi-target',
progress: 60,
progressColor: 'indigo',
successRate: 88,
failureRate: 5,
avgTime: 12,
lastActivity: new Date()
},
{
id: 'nhtsa-fetcher',
name: 'NHTSA Fetcher',
description: 'US vehicle specifications',
region: 'US',
status: 'idle',
statusColor: 'warning',
icon: 'mdi-database-import',
progress: 0,
progressColor: 'orange',
successRate: 95,
failureRate: 2,
avgTime: 8,
lastActivity: new Date(Date.now() - 3600000) // 1 hour ago
},
{
id: 'system-ocr',
name: 'System OCR',
description: 'Document processing AI',
region: 'all',
status: 'running',
statusColor: 'success',
icon: 'mdi-text-recognition',
progress: 90,
progressColor: 'green',
successRate: 85,
failureRate: 8,
avgTime: 25,
lastActivity: new Date()
},
{
id: 'rdw-enricher',
name: 'RDW Enricher',
description: 'Dutch vehicle data',
region: 'EU',
status: 'error',
statusColor: 'error',
icon: 'mdi-alert-circle',
progress: 30,
progressColor: 'red',
successRate: 78,
failureRate: 15,
avgTime: 18,
lastActivity: new Date(Date.now() - 1800000) // 30 minutes ago
},
{
id: 'alchemist-pro',
name: 'Alchemist Pro',
description: 'Gold status optimizer',
region: 'all',
status: 'running',
statusColor: 'success',
icon: 'mdi-star',
progress: 85,
progressColor: 'amber',
successRate: 96,
failureRate: 1,
avgTime: 32,
lastActivity: new Date()
}
])
// Mock data generator
const generateMockLog = (): AiLogEntry => {
const robotList = robots.value.map(r => r.name)
const types: AiLogEntry['type'][] = ['info', 'success', 'warning', 'error', 'gold']
const messages = [
'Vehicle #4521 changed to Gold Status',
'New vehicle discovered in UK catalog',
'DVLA API quota limit reached',
'OCR processing completed for invoice #789',
'Service validation failed - missing coordinates',
'Price comparison completed for 15 services',
'Vehicle technical data enriched successfully',
'Database synchronization in progress',
'AI model training completed',
'Real-time monitoring activated'
]
const robot = robotList[Math.floor(Math.random() * robotList.length)]
const type = types[Math.floor(Math.random() * types.length)]
const message = messages[Math.floor(Math.random() * messages.length)]
return {
id: `log_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date(),
message,
type,
robot,
vehicleId: type === 'gold' ? `#${Math.floor(Math.random() * 10000)}` : undefined,
status: type === 'gold' ? 'GOLD' : type === 'success' ? 'SUCCESS' : type === 'error' ? 'FAILED' : 'PROCESSING',
details: type === 'error' ? 'API timeout after 30 seconds' : undefined,
isNew: true
}
}
// Computed properties
const filteredRobots = computed(() => {
if (selectedRegion.value === 'all') return robots.value
return robots.value.filter(robot => robot.region === selectedRegion.value || robot.region === 'all')
})
const visibleLogs = computed(() => {
// Show latest 5 logs
return [...logs.value].slice(-5).reverse()
})
const activeRobots = computed(() => {
return robots.value.filter(r => r.status === 'running').length
})
const pipelineStats = computed(() => {
const totalRobots = robots.value.length
const runningRobots = robots.value.filter(r => r.status === 'running').length
const totalSuccessRate = robots.value.reduce((sum, r) => sum + r.successRate, 0) / totalRobots
const totalAvgTime = robots.value.reduce((sum, r) => sum + r.avgTime, 0) / totalRobots
return {
totalProcessed: Math.floor(Math.random() * 10000) + 5000,
successRate: Math.round(totalSuccessRate),
avgProcessingTime: Math.round(totalAvgTime),
queueSize: Math.floor(Math.random() * 200),
runningRobots,
totalRobots
}
})
const connectionStatusColor = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'green'
case 'disconnected': return 'orange'
case 'error': return 'red'
default: return 'grey'
}
})
const connectionStatusIcon = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'mdi-check-circle'
case 'disconnected': return 'mdi-alert-circle'
case 'error': return 'mdi-close-circle'
default: return 'mdi-help-circle'
}
})
const connectionStatusText = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'Connected'
case 'disconnected': return 'Disconnected'
case 'error': return 'Error'
default: return 'Unknown'
}
})
const connectionStatusMessage = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'Connected to AI logs stream'
case 'disconnected': return 'Disconnected - using mock data'
case 'error': return 'Connection error - check API endpoint'
default: return 'Status unknown'
}
})
const connectionStatusBarClass = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'bg-green-lighten-5'
case 'disconnected': return 'bg-orange-lighten-5'
case 'error': return 'bg-red-lighten-5'
default: return 'bg-grey-lighten-5'
}
})
// Helper functions
// Helper functions
const getLogColor = (type: AiLogEntry['type']) => {
switch (type) {
case 'info': return 'blue'
case 'success': return 'green'
case 'warning': return 'orange'
case 'error': return 'red'
case 'gold': return 'amber'
default: return 'grey'
}
}
const getLogIcon = (type: AiLogEntry['type']) => {
switch (type) {
case 'info': return 'mdi-information'
case 'success': return 'mdi-check-circle'
case 'warning': return 'mdi-alert'
case 'error': return 'mdi-alert-circle'
case 'gold': return 'mdi-star'
default: return 'mdi-help-circle'
}
}
const getRobotColor = (robotName: string) => {
const robot = robots.value.find(r => r.name === robotName)
return robot?.statusColor || 'grey'
}
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'running': return 'success'
case 'idle': return 'warning'
case 'error': return 'error'
case 'paused': return 'grey'
default: return 'grey'
}
}
const formatTime = (timestamp: Date) => {
const now = new Date()
const diff = now.getTime() - timestamp.getTime()
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
// Data fetching and polling
const fetchLogs = async () => {
if (isRefreshing.value) return
isRefreshing.value = true
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500))
// Add new mock log
const newLog = generateMockLog()
logs.value.push(newLog)
// Keep only last 50 logs
if (logs.value.length > 50) {
logs.value = logs.value.slice(-50)
}
// Mark old logs as not new
setTimeout(() => {
logs.value.forEach(log => {
if (log.isNew && Date.now() - log.timestamp.getTime() > 5000) {
log.isNew = false
}
})
}, 5000)
// Update connection status randomly
if (Math.random() > 0.95) {
connectionStatus.value = 'disconnected'
} else if (Math.random() > 0.98) {
connectionStatus.value = 'error'
} else {
connectionStatus.value = 'connected'
}
} catch (error) {
console.error('Failed to fetch AI logs:', error)
connectionStatus.value = 'error'
} finally {
isRefreshing.value = false
isLoading.value = false
}
}
const forceRefresh = () => {
fetchLogs()
}
const toggleAutoScroll = () => {
autoScroll.value = !autoScroll.value
}
const clearLogs = () => {
logs.value = []
}
const scrollToBottom = () => {
if (logContainer.value && autoScroll.value) {
nextTick(() => {
logContainer.value!.scrollTop = logContainer.value!.scrollHeight
})
}
}
// Polling management
let pollInterval: number | null = null
const startPolling = () => {
if (pollInterval) clearInterval(pollInterval)
pollInterval = setInterval(() => {
fetchLogs()
scrollToBottom()
}, pollingInterval.value) as unknown as number
}
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
// Lifecycle hooks
onMounted(() => {
// Initial load
fetchLogs()
// Start polling
startPolling()
// Generate initial logs
for (let i = 0; i < 10; i++) {
const log = generateMockLog()
log.timestamp = new Date(Date.now() - (10 - i) * 60000) // Staggered times
log.isNew = false
logs.value.push(log)
}
isLoading.value = false
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.log-entries-container {
overflow-y: auto;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
}
.log-entry {
border-left: 3px solid;
background-color: rgba(255, 255, 255, 0.02);
transition: background-color 0.2s;
}
.log-entry:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.log-entry.new-entry {
background-color: rgba(33, 150, 243, 0.1);
border-left-color: #2196f3;
}
.h-100 {
height: 100%;
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<v-card
color="teal-darken-1"
variant="tonal"
class="h-100 d-flex flex-column"
>
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon icon="mdi-chart-line" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">Financial Overview</span>
</div>
<div class="d-flex align-center">
<v-chip size="small" color="green" class="mr-2">
<v-icon icon="mdi-cash" size="small" class="mr-1"></v-icon>
Live
</v-chip>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
size="x-small"
variant="text"
v-bind="props"
class="text-caption"
>
{{ selectedPeriod }}
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="period in periodOptions"
:key="period.value"
@click="selectedPeriod = period.value"
>
<v-list-item-title>{{ period.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</v-card-title>
<v-card-text class="flex-grow-1 pa-0">
<div class="pa-4">
<!-- Key Financial Metrics -->
<v-row dense class="mb-4">
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold text-primary">{{ formatCurrency(revenue) }}</div>
<div class="text-caption text-grey">Revenue</div>
<div class="text-caption" :class="revenueGrowth >= 0 ? 'text-success' : 'text-error'">
<v-icon :icon="revenueGrowth >= 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'" size="x-small" class="mr-1"></v-icon>
{{ Math.abs(revenueGrowth) }}%
</div>
</v-card>
</v-col>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold text-error">{{ formatCurrency(expenses) }}</div>
<div class="text-caption text-grey">Expenses</div>
<div class="text-caption" :class="expenseGrowth <= 0 ? 'text-success' : 'text-error'">
<v-icon :icon="expenseGrowth <= 0 ? 'mdi-arrow-down' : 'mdi-arrow-up'" size="x-small" class="mr-1"></v-icon>
{{ Math.abs(expenseGrowth) }}%
</div>
</v-card>
</v-col>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold text-success">{{ formatCurrency(profit) }}</div>
<div class="text-caption text-grey">Profit</div>
<div class="text-caption" :class="profitMargin >= 20 ? 'text-success' : profitMargin >= 10 ? 'text-warning' : 'text-error'">
{{ profitMargin }}% margin
</div>
</v-card>
</v-col>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold text-indigo">{{ formatCurrency(cashFlow) }}</div>
<div class="text-caption text-grey">Cash Flow</div>
<div class="text-caption" :class="cashFlow >= 0 ? 'text-success' : 'text-error'">
{{ cashFlow >= 0 ? 'Positive' : 'Negative' }}
</div>
</v-card>
</v-col>
</v-row>
<!-- Revenue vs Expenses Chart -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Revenue vs Expenses</div>
<div class="chart-container" style="height: 200px;">
<canvas ref="revenueExpenseChart"></canvas>
</div>
</div>
<!-- Expense Breakdown -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Expense Breakdown</div>
<v-row dense>
<v-col v-for="category in expenseCategories" :key="category.name" cols="6" sm="3">
<v-card variant="outlined" class="pa-2">
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-caption font-weight-medium">{{ category.name }}</div>
<div class="text-caption text-grey">{{ formatCurrency(category.amount) }}</div>
</div>
<div class="text-right">
<div class="text-caption">{{ category.percentage }}%</div>
<v-progress-linear
:model-value="category.percentage"
height="4"
:color="category.color"
class="mt-1"
></v-progress-linear>
</div>
</div>
</v-card>
</v-col>
</v-row>
</div>
<!-- Regional Performance -->
<div>
<div class="text-subtitle-2 font-weight-medium mb-2">Regional Performance</div>
<v-table density="compact" class="elevation-1">
<thead>
<tr>
<th class="text-left">Region</th>
<th class="text-right">Revenue</th>
<th class="text-right">Growth</th>
<th class="text-right">Margin</th>
</tr>
</thead>
<tbody>
<tr v-for="region in regionalPerformance" :key="region.name">
<td class="text-left">
<v-chip size="x-small" :color="getRegionColor(region.name)" class="mr-1">
{{ region.name }}
</v-chip>
</td>
<td class="text-right">{{ formatCurrency(region.revenue) }}</td>
<td class="text-right" :class="region.growth >= 0 ? 'text-success' : 'text-error'">
{{ region.growth >= 0 ? '+' : '' }}{{ region.growth }}%
</td>
<td class="text-right" :class="region.margin >= 20 ? 'text-success' : region.margin >= 10 ? 'text-warning' : 'text-error'">
{{ region.margin }}%
</td>
</tr>
</tbody>
</v-table>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-3">
<div class="d-flex justify-space-between align-center w-100">
<div class="text-caption">
<v-icon icon="mdi-calendar" size="small" class="mr-1"></v-icon>
Last updated: {{ lastUpdated }}
</div>
<div class="d-flex">
<v-btn
size="x-small"
variant="text"
@click="refreshData"
:loading="isRefreshing"
>
<v-icon icon="mdi-refresh" size="small" class="mr-1"></v-icon>
Refresh
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="exportData"
class="ml-2"
>
<v-icon icon="mdi-download" size="small" class="mr-1"></v-icon>
Export
</v-btn>
</div>
</div>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Chart, registerables } from 'chart.js'
// Register Chart.js components
Chart.register(...registerables)
// Types
interface ExpenseCategory {
name: string
amount: number
percentage: number
color: string
}
interface RegionalPerformance {
name: string
revenue: number
growth: number
margin: number
}
// State
const selectedPeriod = ref('month')
const isRefreshing = ref(false)
const revenueExpenseChart = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
// Period options
const periodOptions = [
{ label: 'Last 7 Days', value: 'week' },
{ label: 'Last Month', value: 'month' },
{ label: 'Last Quarter', value: 'quarter' },
{ label: 'Last Year', value: 'year' }
]
// Mock financial data
const revenue = ref(1254300)
const expenses = ref(892500)
const revenueGrowth = ref(12.5)
const expenseGrowth = ref(8.2)
const cashFlow = ref(361800)
// Computed properties
const profit = computed(() => revenue.value - expenses.value)
const profitMargin = computed(() => {
if (revenue.value === 0) return 0
return Math.round((profit.value / revenue.value) * 100)
})
const expenseCategories = ref<ExpenseCategory[]>([
{ name: 'Personnel', amount: 425000, percentage: 48, color: 'indigo' },
{ name: 'Operations', amount: 215000, percentage: 24, color: 'blue' },
{ name: 'Marketing', amount: 125000, percentage: 14, color: 'green' },
{ name: 'Technology', amount: 85000, percentage: 10, color: 'orange' },
{ name: 'Other', amount: 42500, percentage: 5, color: 'grey' }
])
const regionalPerformance = ref<RegionalPerformance[]>([
{ name: 'GB', revenue: 450000, growth: 15.2, margin: 22 },
{ name: 'EU', revenue: 385000, growth: 8.7, margin: 18 },
{ name: 'US', revenue: 275000, growth: 21.5, margin: 25 },
{ name: 'OC', revenue: 144300, growth: 5.3, margin: 12 }
])
const lastUpdated = computed(() => {
const now = new Date()
return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
// Helper functions
const formatCurrency = (amount: number) => {
if (amount >= 1000000) {
return `${(amount / 1000000).toFixed(1)}M`
} else if (amount >= 1000) {
return `${(amount / 1000).toFixed(0)}K`
}
return `${amount.toFixed(0)}`
}
const getRegionColor = (region: string) => {
switch (region) {
case 'GB': return 'blue'
case 'EU': return 'green'
case 'US': return 'red'
case 'OC': return 'orange'
default: return 'grey'
}
}
// Chart functions
const initChart = () => {
if (!revenueExpenseChart.value) return
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy()
}
const ctx = revenueExpenseChart.value.getContext('2d')
if (!ctx) return
// Generate mock data based on selected period
const labels = generateChartLabels()
const revenueData = generateRevenueData()
const expenseData = generateExpenseData()
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Revenue',
data: revenueData,
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Expenses',
data: expenseData,
borderColor: '#F44336',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
tension: 0.4,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 10
}
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: (context) => {
return `${context.dataset.label}: ${formatCurrency(context.raw as number)}`
}
}
}
},
scales: {
x: {
grid: {
display: false
}
},
y: {
beginAtZero: true,
ticks: {
callback: (value) => formatCurrency(value as number)
}
}
}
}
})
}
const generateChartLabels = () => {
switch (selectedPeriod.value) {
case 'week':
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
case 'month':
return ['Week 1', 'Week 2', 'Week 3', 'Week 4']
case 'quarter':
return ['Jan-Mar', 'Apr-Jun', 'Jul-Sep', 'Oct-Dec']
case 'year':
return ['Q1', 'Q2', 'Q3', 'Q4']
default:
return ['Week 1', 'Week 2', 'Week 3', 'Week 4']
}
}
const generateRevenueData = () => {
const base = 100000
const variance = 0.3
const count = generateChartLabels().length
return Array.from({ length: count }, (_, i) => {
const growth = 1 + (i * 0.1)
const random = 1 + (Math.random() * variance * 2 - variance)
return Math.round(base * growth * random)
})
}
const generateExpenseData = () => {
const base = 70000
const variance = 0.2
const count = generateChartLabels().length
return Array.from({ length: count }, (_, i) => {
const growth = 1 + (i * 0.05)
const random = 1 + (Math.random() * variance * 2 - variance)
return Math.round(base * growth * random)
})
}
// Actions
const refreshData = () => {
isRefreshing.value = true
// Simulate API call
setTimeout(() => {
// Update with new random data
revenue.value = Math.round(1254300 * (1 + Math.random() * 0.1 - 0.05))
expenses.value = Math.round(892500 * (1 + Math.random() * 0.1 - 0.05))
revenueGrowth.value = parseFloat((Math.random() * 20 - 5).toFixed(1))
expenseGrowth.value = parseFloat((Math.random() * 15 - 5).toFixed(1))
cashFlow.value = revenue.value - expenses.value
// Update chart
initChart()
isRefreshing.value = false
}, 1000)
}
const exportData = () => {
// Simulate export
const data = {
revenue: revenue.value,
expenses: expenses.value,
profit: profit.value,
profitMargin: profitMargin.value,
period: selectedPeriod.value,
timestamp: new Date().toISOString()
}
const dataStr = JSON.stringify(data, null, 2)
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr)
const exportFileDefaultName = `financial_report_${new Date().toISOString().split('T')[0]}.json`
const linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportFileDefaultName)
linkElement.click()
}
// Lifecycle hooks
onMounted(() => {
nextTick(() => {
initChart()
})
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy()
}
})
// Watch for period changes
watch(selectedPeriod, () => {
initChart()
})
</script>
<style scoped>
.chart-container {
position: relative;
width: 100%;
}
.h-100 {
height: 100%;
}
.v-table {
background: transparent;
}
.v-table :deep(thead) th {
background-color: rgba(0, 0, 0, 0.02);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<v-card
color="orange-darken-1"
variant="tonal"
class="h-100 d-flex flex-column"
>
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon icon="mdi-account-group" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">Sales Pipeline</span>
</div>
<div class="d-flex align-center">
<v-chip size="small" color="green" class="mr-2">
<v-icon icon="mdi-trending-up" size="small" class="mr-1"></v-icon>
Active
</v-chip>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
size="x-small"
variant="text"
v-bind="props"
class="text-caption"
>
{{ selectedTeam }}
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="team in teamOptions"
:key="team.value"
@click="selectedTeam = team.value"
>
<v-list-item-title>{{ team.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</v-card-title>
<v-card-text class="flex-grow-1 pa-0">
<div class="pa-4">
<!-- Pipeline Stages -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Pipeline Stages</div>
<v-row dense>
<v-col v-for="stage in pipelineStages" :key="stage.name" cols="6" sm="3">
<v-card variant="outlined" class="pa-2">
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-caption font-weight-medium">{{ stage.name }}</div>
<div class="text-caption text-grey">{{ stage.count }} leads</div>
</div>
<div class="text-right">
<div class="text-caption" :class="`text-${stage.color}`">{{ stage.conversion }}%</div>
<v-progress-linear
:model-value="stage.conversion"
height="4"
:color="stage.color"
class="mt-1"
></v-progress-linear>
</div>
</div>
<div class="text-caption text-grey mt-1">
Avg: {{ stage.avgDays }} days
</div>
</v-card>
</v-col>
</v-row>
</div>
<!-- Conversion Funnel Chart -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Conversion Funnel</div>
<div class="chart-container" style="height: 180px;">
<canvas ref="funnelChart"></canvas>
</div>
</div>
<!-- Top Performers -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Top Performers</div>
<v-table density="compact" class="elevation-1">
<thead>
<tr>
<th class="text-left">Salesperson</th>
<th class="text-right">Leads</th>
<th class="text-right">Converted</th>
<th class="text-right">Rate</th>
<th class="text-right">Revenue</th>
</tr>
</thead>
<tbody>
<tr v-for="person in topPerformers" :key="person.name">
<td class="text-left">
<div class="d-flex align-center">
<v-avatar size="24" class="mr-2">
<v-img :src="person.avatar" :alt="person.name"></v-img>
</v-avatar>
<span class="text-caption">{{ person.name }}</span>
</div>
</td>
<td class="text-right">{{ person.leads }}</td>
<td class="text-right">{{ person.converted }}</td>
<td class="text-right" :class="person.conversionRate >= 30 ? 'text-success' : person.conversionRate >= 20 ? 'text-warning' : 'text-error'">
{{ person.conversionRate }}%
</td>
<td class="text-right font-weight-medium">{{ formatCurrency(person.revenue) }}</td>
</tr>
</tbody>
</v-table>
</div>
<!-- Recent Activities -->
<div>
<div class="text-subtitle-2 font-weight-medium mb-2">Recent Activities</div>
<div class="activity-list">
<div v-for="activity in recentActivities" :key="activity.id" class="activity-item mb-2 pa-2">
<div class="d-flex align-center">
<v-avatar size="28" class="mr-2">
<v-img :src="activity.avatar" :alt="activity.salesperson"></v-img>
</v-avatar>
<div class="flex-grow-1">
<div class="text-caption">
<span class="font-weight-medium">{{ activity.salesperson }}</span>
{{ activity.action }}
<span class="font-weight-medium">{{ activity.client }}</span>
</div>
<div class="d-flex align-center mt-1">
<v-chip size="x-small" :color="getStageColor(activity.stage)" class="mr-1">
{{ activity.stage }}
</v-chip>
<span class="text-caption text-grey">{{ formatTime(activity.timestamp) }}</span>
</div>
</div>
<v-icon :icon="getActivityIcon(activity.type)" size="small" :color="getActivityColor(activity.type)"></v-icon>
</div>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-3">
<div class="d-flex justify-space-between align-center w-100">
<div class="text-caption">
<v-icon icon="mdi-chart-timeline" size="small" class="mr-1"></v-icon>
Total Pipeline: {{ formatCurrency(totalPipelineValue) }}
</div>
<div class="d-flex">
<v-btn
size="x-small"
variant="text"
@click="refreshPipeline"
:loading="isRefreshing"
>
<v-icon icon="mdi-refresh" size="small" class="mr-1"></v-icon>
Refresh
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="addNewLead"
class="ml-2"
>
<v-icon icon="mdi-plus" size="small" class="mr-1"></v-icon>
New Lead
</v-btn>
</div>
</div>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
// Register Chart.js components
Chart.register(...registerables)
// Types
interface PipelineStage {
name: string
count: number
conversion: number
avgDays: number
color: string
}
interface SalesPerson {
name: string
avatar: string
leads: number
converted: number
conversionRate: number
revenue: number
}
interface Activity {
id: string
salesperson: string
avatar: string
action: string
client: string
stage: string
type: 'call' | 'meeting' | 'email' | 'proposal' | 'closed'
timestamp: Date
}
// State
const selectedTeam = ref('all')
const isRefreshing = ref(false)
const funnelChart = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
// Team options
const teamOptions = [
{ label: 'All Teams', value: 'all' },
{ label: 'Enterprise', value: 'enterprise' },
{ label: 'SMB', value: 'smb' },
{ label: 'Government', value: 'government' }
]
// Pipeline data
const pipelineStages = ref<PipelineStage[]>([
{ name: 'Prospecting', count: 142, conversion: 65, avgDays: 3, color: 'blue' },
{ name: 'Qualification', count: 92, conversion: 45, avgDays: 7, color: 'indigo' },
{ name: 'Proposal', count: 41, conversion: 30, avgDays: 14, color: 'orange' },
{ name: 'Negotiation', count: 28, conversion: 20, avgDays: 21, color: 'red' },
{ name: 'Closed Won', count: 12, conversion: 15, avgDays: 30, color: 'green' }
])
const topPerformers = ref<SalesPerson[]>([
{ name: 'Alex Johnson', avatar: 'https://i.pravatar.cc/150?img=1', leads: 45, converted: 18, conversionRate: 40, revenue: 125000 },
{ name: 'Maria Garcia', avatar: 'https://i.pravatar.cc/150?img=2', leads: 38, converted: 15, conversionRate: 39, revenue: 112000 },
{ name: 'David Chen', avatar: 'https://i.pravatar.cc/150?img=3', leads: 42, converted: 16, conversionRate: 38, revenue: 108000 },
{ name: 'Sarah Williams', avatar: 'https://i.pravatar.cc/150?img=4', leads: 35, converted: 13, conversionRate: 37, revenue: 98000 }
])
const recentActivities = ref<Activity[]>([
{ id: '1', salesperson: 'Alex Johnson', avatar: 'https://i.pravatar.cc/150?img=1', action: 'sent proposal to', client: 'TechCorp Inc.', stage: 'Proposal', type: 'proposal', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', salesperson: 'Maria Garcia', avatar: 'https://i.pravatar.cc/150?img=2', action: 'closed deal with', client: 'Global Motors', stage: 'Closed Won', type: 'closed', timestamp: new Date(Date.now() - 7200000) },
{ id: '3', salesperson: 'David Chen', avatar: 'https://i.pravatar.cc/150?img=3', action: 'scheduled meeting with', client: 'HealthPlus', stage: 'Qualification', type: 'meeting', timestamp: new Date(Date.now() - 10800000) },
{ id: '4', salesperson: 'Sarah Williams', avatar: 'https://i.pravatar.cc/150?img=4', action: 'called', client: 'EduTech Solutions', stage: 'Prospecting', type: 'call', timestamp: new Date(Date.now() - 14400000) }
])
// Computed properties
const totalPipelineValue = computed(() => {
return pipelineStages.value.reduce((total, stage) => {
// Estimate value based on stage
const stageValue = stage.count * 5000 // Average deal size
return total + stageValue
}, 0)
})
// Helper functions
const formatCurrency = (amount: number) => {
if (amount >= 1000000) {
return `${(amount / 1000000).toFixed(1)}M`
} else if (amount >= 1000) {
return `${(amount / 1000).toFixed(0)}K`
}
return `${amount.toFixed(0)}`
}
const formatTime = (timestamp: Date) => {
const now = new Date()
const diff = now.getTime() - timestamp.getTime()
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const getStageColor = (stage: string) => {
switch (stage.toLowerCase()) {
case 'prospecting': return 'blue'
case 'qualification': return 'indigo'
case 'proposal': return 'orange'
case 'negotiation': return 'red'
case 'closed won': return 'green'
default: return 'grey'
}
}
const getActivityIcon = (type: Activity['type']) => {
switch (type) {
case 'call': return 'mdi-phone'
case 'meeting': return 'mdi-calendar'
case 'email': return 'mdi-email'
case 'proposal': return 'mdi-file-document'
case 'closed': return 'mdi-check-circle'
default: return 'mdi-help-circle'
}
}
const getActivityColor = (type: Activity['type']) => {
switch (type) {
case 'call': return 'blue'
case 'meeting': return 'indigo'
case 'email': return 'green'
case 'proposal': return 'orange'
case 'closed': return 'success'
default: return 'grey'
}
}
// Chart functions
const initChart = () => {
if (!funnelChart.value) return
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy()
}
const ctx = funnelChart.value.getContext('2d')
if (!ctx) return
// Prepare funnel data
const labels = pipelineStages.value.map(stage => stage.name)
const data = pipelineStages.value.map(stage => stage.count)
const backgroundColors = pipelineStages.value.map(stage => {
switch (stage.color) {
case 'blue': return '#2196F3'
case 'indigo': return '#3F51B5'
case 'orange': return '#FF9800'
case 'red': return '#F44336'
case 'green': return '#4CAF50'
default: return '#9E9E9E'
}
})
chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Leads',
data,
backgroundColor: backgroundColors,
borderColor: backgroundColors.map(color => color.replace('0.8', '1')),
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y', // Horizontal bar chart for funnel
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context) => {
const stage = pipelineStages.value[context.dataIndex]
return `${context.dataset.label}: ${context.raw} (${stage.conversion}% conversion)`
}
}
}
},
scales: {
x: {
beginAtZero: true,
grid: {
display: false
},
title: {
display: true,
text: 'Number of Leads'
}
},
y: {
grid: {
display: false
}
}
}
}
})
}
// Actions
const refreshPipeline = () => {
isRefreshing.value = true
// Simulate API call
setTimeout(() => {
// Update with new random data
pipelineStages.value.forEach(stage => {
stage.count = Math.round(stage.count * (1 + Math.random() * 0.2 - 0.1))
stage.conversion = Math.round(stage.conversion * (1 + Math.random() * 0.1 - 0.05))
})
// Update top performers
topPerformers.value.forEach(person => {
person.leads = Math.round(person.leads * (1 + Math.random() * 0.1 - 0.05))
person.converted = Math.round(person.converted * (1 + Math.random() * 0.1 - 0.05))
person.conversionRate = Math.round((person.converted / person.leads) * 100)
person.revenue = Math.round(person.revenue * (1 + Math.random() * 0.15 - 0.05))
})
// Add new activity
const activities = ['called', 'emailed', 'met with', 'sent proposal to', 'closed deal with']
const clients = ['TechCorp', 'Global Motors', 'HealthPlus', 'EduTech', 'FinancePro', 'AutoGroup']
const salespeople = topPerformers.value
const newActivity: Activity = {
id: `act_${Date.now()}`,
salesperson: salespeople[Math.floor(Math.random() * salespeople.length)].name,
avatar: `https://i.pravatar.cc/150?img=${Math.floor(Math.random() * 10) + 1}`,
action: activities[Math.floor(Math.random() * activities.length)],
client: clients[Math.floor(Math.random() * clients.length)],
stage: pipelineStages.value[Math.floor(Math.random() * pipelineStages.value.length)].name,
type: ['call', 'meeting', 'email', 'proposal', 'closed'][Math.floor(Math.random() * 5)] as Activity['type'],
timestamp: new Date()
}
recentActivities.value.unshift(newActivity)
// Keep only last 5 activities
if (recentActivities.value.length > 5) {
recentActivities.value = recentActivities.value.slice(0, 5)
}
// Update chart
initChart()
isRefreshing.value = false
}, 1000)
}
const addNewLead = () => {
// Simulate adding new lead
pipelineStages.value[0].count += 1
// Show notification
console.log('New lead added to pipeline')
}
// Lifecycle hooks
onMounted(() => {
nextTick(() => {
initChart()
})
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy()
}
})
</script>
<style scoped>
.chart-container {
position: relative;
width: 100%;
}
.h-100 {
height: 100%;
}
.activity-list {
max-height: 150px;
overflow-y: auto;
}
.activity-item {
border-left: 3px solid;
background-color: rgba(255, 255, 255, 0.02);
transition: background-color 0.2s;
border-left-color: #FF9800; /* Orange accent */
}
.activity-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.v-table {
background: transparent;
}
.v-table :deep(thead) th {
background-color: rgba(0, 0, 0, 0.02);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<TileWrapper
title="Geographical Map"
subtitle="Service moderation map"
icon="map"
:loading="loading"
>
<div class="service-map-tile">
<div class="mini-map">
<div class="map-placeholder">
<div class="map-grid">
<div
v-for="point in mapPoints"
:key="point.id"
class="map-point"
:class="point.status"
:style="{
left: `${point.x}%`,
top: `${point.y}%`
}"
:title="point.name"
></div>
</div>
</div>
</div>
<div class="tile-stats">
<div class="stat">
<span class="stat-label">Pending in Scope</span>
<span class="stat-value">{{ pendingCount }}</span>
</div>
<div class="stat">
<span class="stat-label">Scope</span>
<span class="stat-value scope">{{ scopeLabel }}</span>
</div>
</div>
<div class="tile-actions">
<button @click="navigateToMap" class="btn-primary">
Open Full Map
</button>
<button @click="refresh" class="btn-secondary">
Refresh
</button>
</div>
</div>
</TileWrapper>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import TileWrapper from '~/components/TileWrapper.vue'
import { useServiceMap } from '~/composables/useServiceMap'
const router = useRouter()
const { pendingServices, scopeLabel } = useServiceMap()
const loading = ref(false)
const pendingCount = computed(() => pendingServices.value.length)
// Generate random points for the mini map visualization
const mapPoints = computed(() => {
return pendingServices.value.slice(0, 8).map((service, index) => ({
id: service.id,
name: service.name,
status: service.status,
x: 10 + (index % 4) * 25 + Math.random() * 10,
y: 10 + Math.floor(index / 4) * 30 + Math.random() * 10
}))
})
const navigateToMap = () => {
router.push('/moderation-map')
}
const refresh = () => {
loading.value = true
// Simulate API call
setTimeout(() => {
loading.value = false
}, 1000)
}
</script>
<style scoped>
.service-map-tile {
display: flex;
flex-direction: column;
height: 100%;
}
.mini-map {
flex: 1;
margin-bottom: 15px;
}
.map-placeholder {
width: 100%;
height: 150px;
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
border-radius: 8px;
position: relative;
overflow: hidden;
border: 1px solid #90caf9;
}
.map-grid {
position: relative;
width: 100%;
height: 100%;
}
.map-point {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
transform: translate(-50%, -50%);
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.map-point.pending {
background-color: #3b82f6;
}
.map-point.approved {
background-color: #28a745;
}
.tile-stats {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-label {
font-size: 0.85rem;
color: #666;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #333;
}
.stat-value.scope {
font-size: 1rem;
color: #4a90e2;
background: #e3f2fd;
padding: 4px 8px;
border-radius: 12px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tile-actions {
display: flex;
gap: 10px;
}
.btn-primary {
flex: 2;
background-color: #4a90e2;
color: white;
border: none;
padding: 10px;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: #3a7bc8;
}
.btn-secondary {
flex: 1;
background-color: #f8f9fa;
color: #495057;
border: 1px solid #dee2e6;
padding: 10px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #e9ecef;
}
</style>

View File

@@ -0,0 +1,590 @@
<template>
<v-card
color="blue-grey-darken-1"
variant="tonal"
class="h-100 d-flex flex-column"
>
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon icon="mdi-server" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">System Health</span>
</div>
<div class="d-flex align-center">
<v-chip size="small" :color="overallStatusColor" class="mr-2">
<v-icon :icon="overallStatusIcon" size="small" class="mr-1"></v-icon>
{{ overallStatusText }}
</v-chip>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
size="x-small"
variant="text"
v-bind="props"
class="text-caption"
>
{{ selectedEnvironment }}
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="env in environmentOptions"
:key="env.value"
@click="selectedEnvironment = env.value"
>
<v-list-item-title>{{ env.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</v-card-title>
<v-card-text class="flex-grow-1 pa-0">
<div class="pa-4">
<!-- System Status Overview -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">System Status</div>
<v-row dense>
<v-col v-for="component in systemComponents" :key="component.name" cols="6" sm="3">
<v-card variant="outlined" class="pa-2">
<div class="d-flex align-center justify-space-between">
<div>
<div class="d-flex align-center">
<v-icon :icon="component.icon" size="small" :color="component.statusColor" class="mr-2"></v-icon>
<span class="text-caption font-weight-medium">{{ component.name }}</span>
</div>
<div class="text-caption text-grey mt-1">{{ component.description }}</div>
</div>
<div class="text-right">
<div class="text-caption" :class="`text-${component.statusColor}`">{{ component.status }}</div>
<div class="text-caption text-grey">{{ component.uptime }}%</div>
</div>
</div>
<!-- Response Time Indicator -->
<div v-if="component.responseTime" class="mt-2">
<div class="d-flex justify-space-between">
<span class="text-caption text-grey">Response</span>
<span class="text-caption" :class="getResponseTimeColor(component.responseTime)">{{ component.responseTime }}ms</span>
</div>
<v-progress-linear
:model-value="Math.min(component.responseTime / 10, 100)"
height="4"
:color="getResponseTimeColor(component.responseTime)"
class="mt-1"
></v-progress-linear>
</div>
</v-card>
</v-col>
</v-row>
</div>
<!-- API Response Times Chart -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">API Response Times (Last 24h)</div>
<div class="chart-container" style="height: 150px;">
<canvas ref="responseTimeChart"></canvas>
</div>
</div>
<!-- Database Metrics -->
<div class="mb-4">
<div class="text-subtitle-2 font-weight-medium mb-2">Database Metrics</div>
<v-row dense>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold" :class="databaseMetrics.connections > 80 ? 'text-error' : 'text-success'">{{ databaseMetrics.connections }}</div>
<div class="text-caption text-grey">Connections</div>
<div class="text-caption">{{ databaseMetrics.activeConnections }} active</div>
</v-card>
</v-col>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold" :class="databaseMetrics.queryTime > 500 ? 'text-error' : databaseMetrics.queryTime > 200 ? 'text-warning' : 'text-success'">{{ databaseMetrics.queryTime }}ms</div>
<div class="text-caption text-grey">Avg Query Time</div>
<div class="text-caption">{{ databaseMetrics.queriesPerSecond }} qps</div>
</v-card>
</v-col>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold" :class="databaseMetrics.cacheHitRate < 80 ? 'text-error' : databaseMetrics.cacheHitRate < 90 ? 'text-warning' : 'text-success'">{{ databaseMetrics.cacheHitRate }}%</div>
<div class="text-caption text-grey">Cache Hit Rate</div>
<div class="text-caption">{{ formatBytes(databaseMetrics.cacheSize) }} cache</div>
</v-card>
</v-col>
<v-col cols="6" sm="3">
<v-card variant="outlined" class="pa-2 text-center">
<div class="text-h6 font-weight-bold" :class="databaseMetrics.replicationLag > 1000 ? 'text-error' : databaseMetrics.replicationLag > 500 ? 'text-warning' : 'text-success'">{{ databaseMetrics.replicationLag }}ms</div>
<div class="text-caption text-grey">Replication Lag</div>
<div class="text-caption">{{ databaseMetrics.replicationStatus }}</div>
</v-card>
</v-col>
</v-row>
</div>
<!-- Server Resources -->
<div>
<div class="text-subtitle-2 font-weight-medium mb-2">Server Resources</div>
<v-row dense>
<v-col cols="12" sm="6">
<v-card variant="outlined" class="pa-3">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption font-weight-medium">CPU Usage</span>
<span class="text-caption" :class="serverResources.cpu > 80 ? 'text-error' : serverResources.cpu > 60 ? 'text-warning' : 'text-success'">{{ serverResources.cpu }}%</span>
</div>
<v-progress-linear
:model-value="serverResources.cpu"
height="8"
:color="serverResources.cpu > 80 ? 'error' : serverResources.cpu > 60 ? 'warning' : 'success'"
rounded
></v-progress-linear>
<div class="text-caption text-grey mt-1">{{ serverResources.cpuCores }} cores @ {{ serverResources.cpuFrequency }}GHz</div>
</v-card>
</v-col>
<v-col cols="12" sm="6">
<v-card variant="outlined" class="pa-3">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption font-weight-medium">Memory Usage</span>
<span class="text-caption" :class="serverResources.memory > 80 ? 'text-error' : serverResources.memory > 60 ? 'text-warning' : 'text-success'">{{ serverResources.memory }}%</span>
</div>
<v-progress-linear
:model-value="serverResources.memory"
height="8"
:color="serverResources.memory > 80 ? 'error' : serverResources.memory > 60 ? 'warning' : 'success'"
rounded
></v-progress-linear>
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.memoryUsed) }} / {{ formatBytes(serverResources.memoryTotal) }}</div>
</v-card>
</v-col>
<v-col cols="12" sm="6">
<v-card variant="outlined" class="pa-3">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption font-weight-medium">Disk I/O</span>
<span class="text-caption" :class="serverResources.diskIO > 80 ? 'text-error' : serverResources.diskIO > 60 ? 'text-warning' : 'text-success'">{{ serverResources.diskIO }}%</span>
</div>
<v-progress-linear
:model-value="serverResources.diskIO"
height="8"
:color="serverResources.diskIO > 80 ? 'error' : serverResources.diskIO > 60 ? 'warning' : 'success'"
rounded
></v-progress-linear>
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.diskRead) }}/s read, {{ formatBytes(serverResources.diskWrite) }}/s write</div>
</v-card>
</v-col>
<v-col cols="12" sm="6">
<v-card variant="outlined" class="pa-3">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption font-weight-medium">Network</span>
<span class="text-caption" :class="serverResources.network > 80 ? 'text-error' : serverResources.network > 60 ? 'text-warning' : 'text-success'">{{ serverResources.network }}%</span>
</div>
<v-progress-linear
:model-value="serverResources.network"
height="8"
:color="serverResources.network > 80 ? 'error' : serverResources.network > 60 ? 'warning' : 'success'"
rounded
></v-progress-linear>
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.networkIn) }}/s in, {{ formatBytes(serverResources.networkOut) }}/s out</div>
</v-card>
</v-col>
</v-row>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-3">
<div class="d-flex justify-space-between align-center w-100">
<div class="text-caption">
<v-icon icon="mdi-clock" size="small" class="mr-1"></v-icon>
Last check: {{ lastCheckTime }}
<v-chip size="x-small" color="green" class="ml-2">
Auto-refresh: {{ refreshInterval }}s
</v-chip>
</div>
<div class="d-flex">
<v-btn
size="x-small"
variant="text"
@click="refreshHealth"
:loading="isRefreshing"
>
<v-icon icon="mdi-refresh" size="small" class="mr-1"></v-icon>
Refresh
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="toggleAutoRefresh"
:color="autoRefresh ? 'primary' : 'grey'"
class="ml-2"
>
<v-icon :icon="autoRefresh ? 'mdi-pause' : 'mdi-play'" size="small" class="mr-1"></v-icon>
{{ autoRefresh ? 'Pause' : 'Resume' }}
</v-btn>
</div>
</div>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
// Register Chart.js components
Chart.register(...registerables)
// Types
interface SystemComponent {
name: string
description: string
status: 'healthy' | 'degraded' | 'down'
statusColor: string
icon: string
uptime: number
responseTime?: number
}
interface DatabaseMetrics {
connections: number
activeConnections: number
queryTime: number
queriesPerSecond: number
cacheHitRate: number
cacheSize: number
replicationLag: number
replicationStatus: string
}
interface ServerResources {
cpu: number
cpuCores: number
cpuFrequency: number
memory: number
memoryUsed: number
memoryTotal: number
diskIO: number
diskRead: number
diskWrite: number
network: number
networkIn: number
networkOut: number
}
// State
const selectedEnvironment = ref('production')
const isRefreshing = ref(false)
const autoRefresh = ref(true)
const refreshInterval = ref(30)
const responseTimeChart = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
let refreshTimer: number | null = null
// Environment options
const environmentOptions = [
{ label: 'Production', value: 'production' },
{ label: 'Staging', value: 'staging' },
{ label: 'Development', value: 'development' },
{ label: 'Testing', value: 'testing' }
]
// System components data
const systemComponents = ref<SystemComponent[]>([
{ name: 'API Gateway', description: 'Main API endpoint', status: 'healthy', statusColor: 'success', icon: 'mdi-api', uptime: 99.9, responseTime: 45 },
{ name: 'Database', description: 'PostgreSQL cluster', status: 'healthy', statusColor: 'success', icon: 'mdi-database', uptime: 99.95, responseTime: 120 },
{ name: 'Cache', description: 'Redis cache layer', status: 'healthy', statusColor: 'success', icon: 'mdi-memory', uptime: 99.8, responseTime: 8 },
{ name: 'Message Queue', description: 'RabbitMQ broker', status: 'degraded', statusColor: 'warning', icon: 'mdi-message-processing', uptime: 98.5, responseTime: 250 },
{ name: 'File Storage', description: 'S3-compatible storage', status: 'healthy', statusColor: 'success', icon: 'mdi-file-cloud', uptime: 99.7, responseTime: 180 },
{ name: 'Authentication', description: 'OAuth2/JWT service', status: 'healthy', statusColor: 'success', icon: 'mdi-shield-account', uptime: 99.9, responseTime: 65 },
{ name: 'Monitoring', description: 'Prometheus/Grafana', status: 'healthy', statusColor: 'success', icon: 'mdi-chart-line', uptime: 99.8, responseTime: 95 },
{ name: 'Load Balancer', description: 'Nginx reverse proxy', status: 'healthy', statusColor: 'success', icon: 'mdi-load-balancer', uptime: 99.99, responseTime: 12 }
])
const databaseMetrics = ref<DatabaseMetrics>({
connections: 64,
activeConnections: 42,
queryTime: 85,
queriesPerSecond: 1250,
cacheHitRate: 92,
cacheSize: 2147483648, // 2GB
replicationLag: 45,
replicationStatus: 'Synced'
})
const serverResources = ref<ServerResources>({
cpu: 42,
cpuCores: 8,
cpuFrequency: 3.2,
memory: 68,
memoryUsed: 1090519040, // ~1GB
memoryTotal: 17179869184, // 16GB
diskIO: 28,
diskRead: 5242880, // 5MB/s
diskWrite: 1048576, // 1MB/s
network: 45,
networkIn: 2097152, // 2MB/s
networkOut: 1048576 // 1MB/s
})
// Computed properties
const overallStatus = computed(() => {
const healthyCount = systemComponents.value.filter(c => c.status === 'healthy').length
const totalCount = systemComponents.value.length
if (healthyCount === totalCount) return 'healthy'
if (healthyCount >= totalCount * 0.8) return 'degraded'
return 'critical'
})
const overallStatusColor = computed(() => {
switch (overallStatus.value) {
case 'healthy': return 'green'
case 'degraded': return 'orange'
case 'critical': return 'red'
default: return 'grey'
}
})
const overallStatusIcon = computed(() => {
switch (overallStatus.value) {
case 'healthy': return 'mdi-check-circle'
case 'degraded': return 'mdi-alert-circle'
case 'critical': return 'mdi-close-circle'
default: return 'mdi-help-circle'
}
})
const overallStatusText = computed(() => {
switch (overallStatus.value) {
case 'healthy': return 'All Systems Normal'
case 'degraded': return 'Minor Issues'
case case 'critical': return 'Critical Issues'
default: return 'Unknown'
}
})
const lastCheckTime = computed(() => {
const now = new Date()
return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
})
// Helper functions
const getResponseTimeColor = (responseTime: number) => {
if (responseTime < 100) return 'success'
if (responseTime < 300) return 'warning'
return 'error'
}
const formatBytes = (bytes: number) => {
if (bytes >= 1073741824) {
return `${(bytes / 1073741824).toFixed(1)} GB`
} else if (bytes >= 1048576) {
return `${(bytes / 1048576).toFixed(1)} MB`
} else if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${bytes} B`
}
// Chart functions
const initChart = () => {
if (!responseTimeChart.value) return
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy()
}
const ctx = responseTimeChart.value.getContext('2d')
if (!ctx) return
// Generate mock response time data for last 24 hours
const labels = Array.from({ length: 24 }, (_, i) => {
const hour = new Date(Date.now() - (23 - i) * 3600000)
return hour.getHours().toString().padStart(2, '0') + ':00'
})
const data = labels.map(() => {
const base = 50
const spike = Math.random() > 0.9 ? 300 : 0
const variance = Math.random() * 40
return Math.round(base + variance + spike)
})
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'API Response Time (ms)',
data,
borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
tension: 0.4,
fill: true,
pointBackgroundColor: (context) => {
const value = context.dataset.data[context.dataIndex] as number
return value > 200 ? '#F44336' : value > 100 ? '#FF9800' : '#4CAF50'
},
pointBorderColor: '#FFFFFF',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context) => {
return `Response Time: ${context.raw}ms`
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
maxRotation: 0,
callback: (value, index) => {
// Show only every 3rd hour label
return index % 3 === 0 ? labels[index] : ''
}
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Milliseconds (ms)'
},
ticks: {
callback: (value) => `${value}ms`
}
}
}
}
})
}
// Auto-refresh management
const startAutoRefresh = () => {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = setInterval(() => {
refreshHealth()
}, refreshInterval.value * 1000) as unknown as number
}
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
const toggleAutoRefresh = () => {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// Actions
const refreshHealth = () => {
if (isRefreshing.value) return
isRefreshing.value = true
// Simulate API call
setTimeout(() => {
// Update system components with random variations
systemComponents.value.forEach(component => {
// Random status changes (rare)
if (Math.random() > 0.95) {
component.status = Math.random() > 0.7 ? 'degraded' : 'healthy'
component.statusColor = component.status === 'healthy' ? 'success' : 'warning'
}
// Update response times
if (component.responseTime) {
const variation = Math.random() * 40 - 20
component.responseTime = Math.max(10, Math.round(component.responseTime + variation))
}
// Update uptime (slight variations)
component.uptime = Math.min(99.99, component.uptime + (Math.random() * 0.1 - 0.05))
})
// Update database metrics
databaseMetrics.value.connections = Math.round(64 + Math.random() * 20 - 10)
databaseMetrics.value.activeConnections = Math.round(databaseMetrics.value.connections * 0.7)
databaseMetrics.value.queryTime = Math.round(85 + Math.random() * 30 - 15)
databaseMetrics.value.queriesPerSecond = Math.round(1250 + Math.random() * 200 - 100)
databaseMetrics.value.cacheHitRate = Math.min(99, Math.round(92 + Math.random() * 4 - 2))
databaseMetrics.value.replicationLag = Math.round(45 + Math.random() * 20 - 10)
// Update server resources
serverResources.value.cpu = Math.round(42 + Math.random() * 20 - 10)
serverResources.value.memory = Math.round(68 + Math.random() * 10 - 5)
serverResources.value.diskIO = Math.round(28 + Math.random() * 15 - 7)
serverResources.value.network = Math.round(45 + Math.random() * 20 - 10)
// Update chart
initChart()
isRefreshing.value = false
}, 800)
}
// Lifecycle hooks
onMounted(() => {
nextTick(() => {
initChart()
})
// Start auto-refresh if enabled
if (autoRefresh.value) {
startAutoRefresh()
}
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy()
}
stopAutoRefresh()
})
</script>
<style scoped>
.chart-container {
position: relative;
width: 100%;
}
.h-100 {
height: 100%;
}
.v-progress-linear {
border-radius: 4px;
}
.v-card {
transition: all 0.3s ease;
}
.v-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<v-card
:color="tileColor"
variant="tonal"
class="h-100 d-flex flex-column"
@click="handleTileClick"
>
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon :icon="tileIcon" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">{{ tile.title }}</span>
</div>
<v-chip size="small" :color="accessLevelColor" class="text-caption">
{{ accessLevelText }}
</v-chip>
</v-card-title>
<v-card-text class="flex-grow-1">
<p class="text-body-2">{{ tile.description }}</p>
<!-- Requirements Badges -->
<div class="mt-2">
<v-chip
v-for="role in tile.requiredRole"
:key="role"
size="x-small"
class="mr-1 mb-1"
variant="outlined"
>
{{ role }}
</v-chip>
<v-chip
v-if="tile.minRank"
size="x-small"
class="mr-1 mb-1"
color="warning"
variant="outlined"
>
Rank {{ tile.minRank }}+
</v-chip>
</div>
<!-- Scope Level Indicator -->
<div v-if="tile.scopeLevel && tile.scopeLevel.length > 0" class="mt-2">
<v-icon icon="mdi-map-marker" size="small" class="mr-1"></v-icon>
<span class="text-caption">
{{ tile.scopeLevel.join(', ') }}
</span>
</div>
</v-card-text>
<v-card-actions class="mt-auto">
<v-spacer></v-spacer>
<v-btn
variant="text"
size="small"
:prepend-icon="actionIcon"
@click.stop="handleTileClick"
>
Open
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TilePermission } from '~/composables/useRBAC'
interface Props {
tile: TilePermission
}
const props = defineProps<Props>()
// Tile color based on ID
const tileColor = computed(() => {
const colors: Record<string, string> = {
'ai-logs': 'indigo',
'financial-dashboard': 'green',
'salesperson-hub': 'orange',
'user-management': 'blue',
'service-moderation-map': 'teal',
'gamification-control': 'purple',
'system-health': 'red'
}
return colors[props.tile.id] || 'surface'
})
// Tile icon based on ID
const tileIcon = computed(() => {
const icons: Record<string, string> = {
'ai-logs': 'mdi-robot',
'financial-dashboard': 'mdi-chart-line',
'salesperson-hub': 'mdi-account-tie',
'user-management': 'mdi-account-group',
'service-moderation-map': 'mdi-map',
'gamification-control': 'mdi-trophy',
'system-health': 'mdi-heart-pulse'
}
return icons[props.tile.id] || 'mdi-view-dashboard'
})
// Action icon
const actionIcon = computed(() => {
const actions: Record<string, string> = {
'ai-logs': 'mdi-chart-timeline',
'financial-dashboard': 'mdi-finance',
'salesperson-hub': 'mdi-chart-bar',
'user-management': 'mdi-account-cog',
'service-moderation-map': 'mdi-map-search',
'gamification-control': 'mdi-cog',
'system-health': 'mdi-monitor-dashboard'
}
return actions[props.tile.id] || 'mdi-open-in-new'
})
// Access level indicator
const accessLevelColor = computed(() => {
if (props.tile.requiredRole.includes('superadmin')) return 'purple'
if (props.tile.requiredRole.includes('admin')) return 'blue'
if (props.tile.requiredRole.includes('moderator')) return 'green'
return 'orange'
})
const accessLevelText = computed(() => {
if (props.tile.requiredRole.includes('superadmin')) return 'Superadmin'
if (props.tile.requiredRole.includes('admin')) return 'Admin'
if (props.tile.requiredRole.includes('moderator')) return 'Moderator'
return 'Sales'
})
// Handle tile click
function handleTileClick() {
const routes: Record<string, string> = {
'ai-logs': '/ai-logs',
'financial-dashboard': '/finance',
'salesperson-hub': '/sales',
'user-management': '/users',
'service-moderation-map': '/map',
'gamification-control': '/gamification',
'system-health': '/system'
}
const route = routes[props.tile.id]
if (route) {
navigateTo(route)
} else {
console.warn(`No route defined for tile: ${props.tile.id}`)
}
}
</script>
<style scoped>
.v-card {
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.v-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.h-100 {
height: 100%;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<v-card
:color="tileColor"
variant="tonal"
class="h-100 d-flex flex-column tile-wrapper"
:class="{ 'draggable-tile': draggable }"
>
<!-- Drag Handle -->
<div v-if="draggable" class="drag-handle d-flex align-center justify-center pa-2" @mousedown.prevent>
<v-icon icon="mdi-drag-vertical" size="small" class="text-disabled"></v-icon>
</div>
<!-- Tile Header -->
<v-card-title class="d-flex align-center justify-space-between pa-3 pb-0">
<div class="d-flex align-center">
<v-icon :icon="tileIcon" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">{{ tile.title }}</span>
</div>
<div class="d-flex align-center">
<!-- RBAC Badge -->
<v-chip size="small" :color="accessLevelColor" class="text-caption mr-1">
{{ accessLevelText }}
</v-chip>
<!-- Visibility Toggle -->
<v-btn
v-if="showVisibilityToggle"
icon
size="x-small"
variant="text"
@click="toggleVisibility"
:title="tile.preference?.visible ? 'Hide tile' : 'Show tile'"
>
<v-icon :icon="tile.preference?.visible ? 'mdi-eye' : 'mdi-eye-off'"></v-icon>
</v-btn>
</div>
</v-card-title>
<!-- Tile Content Slot -->
<v-card-text class="flex-grow-1 pa-3">
<slot>
<!-- Default content if no slot provided -->
<p class="text-body-2">{{ tile.description }}</p>
</slot>
</v-card-text>
<!-- Tile Footer Actions -->
<v-card-actions class="mt-auto pa-3 pt-0">
<v-spacer></v-spacer>
<v-btn
v-if="showActionButton"
variant="text"
size="small"
:prepend-icon="actionIcon"
@click="handleTileClick"
>
{{ actionText }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTileStore } from '~/stores/tiles'
import type { TilePermission } from '~/composables/useRBAC'
interface Props {
tile: TilePermission
draggable?: boolean
showVisibilityToggle?: boolean
showActionButton?: boolean
actionIcon?: string
actionText?: string
}
const props = withDefaults(defineProps<Props>(), {
draggable: true,
showVisibilityToggle: true,
showActionButton: true,
actionIcon: 'mdi-open-in-new',
actionText: 'Open'
})
const emit = defineEmits<{
click: [tile: TilePermission]
toggleVisibility: [tileId: string, visible: boolean]
}>()
const tileStore = useTileStore()
// Tile color based on ID
const tileColor = computed(() => {
const colors: Record<string, string> = {
'ai-logs': 'indigo',
'financial-dashboard': 'green',
'salesperson-hub': 'orange',
'user-management': 'blue',
'service-moderation-map': 'teal',
'gamification-control': 'purple',
'system-health': 'red'
}
return colors[props.tile.id] || 'surface'
})
// Tile icon based on ID
const tileIcon = computed(() => {
const icons: Record<string, string> = {
'ai-logs': 'mdi-robot',
'financial-dashboard': 'mdi-chart-line',
'salesperson-hub': 'mdi-account-tie',
'user-management': 'mdi-account-group',
'service-moderation-map': 'mdi-map',
'gamification-control': 'mdi-trophy',
'system-health': 'mdi-heart-pulse'
}
return icons[props.tile.id] || 'mdi-view-dashboard'
})
// Access level indicator
const accessLevelColor = computed(() => {
if (props.tile.minRank && props.tile.minRank > 5) return 'warning'
if (props.tile.requiredRole?.includes('admin')) return 'error'
return 'success'
})
const accessLevelText = computed(() => {
if (props.tile.minRank) return `Rank ${props.tile.minRank}+`
if (props.tile.requiredRole?.length) return props.tile.requiredRole[0]
return 'All'
})
// Methods
function handleTileClick() {
emit('click', props.tile)
}
function toggleVisibility() {
const newVisible = !props.tile.preference?.visible
tileStore.toggleTileVisibility(props.tile.id)
emit('toggleVisibility', props.tile.id, newVisible)
}
</script>
<style scoped>
.tile-wrapper {
position: relative;
transition: box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.tile-wrapper:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.drag-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 24px;
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
cursor: grab;
z-index: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.draggable-tile {
user-select: none;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<div class="service-map-container">
<div class="scope-indicator">
<span class="badge">Current Scope: {{ scopeLabel }}</span>
</div>
<div class="map-wrapper">
<l-map
ref="map"
:zoom="zoom"
:center="center"
@ready="onMapReady"
style="height: 600px; width: 100%;"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
<l-marker
v-for="service in services"
:key="service.id"
:lat-lng="[service.lat, service.lng]"
@click="openPopup(service)"
>
<l-icon
:icon-url="getMarkerIcon(service.status)"
:icon-size="[32, 32]"
:icon-anchor="[16, 32]"
/>
<l-popup v-if="selectedService?.id === service.id">
<div class="popup-content">
<h3>{{ service.name }}</h3>
<p><strong>Status:</strong> <span :class="service.status">{{ service.status }}</span></p>
<p><strong>Address:</strong> {{ service.address }}</p>
<p><strong>Distance:</strong> {{ service.distance }} km</p>
<button @click="approveService(service)" class="btn-approve">Approve</button>
</div>
</l-popup>
</l-marker>
</l-map>
</div>
<div class="legend">
<div class="legend-item">
<img src="/marker-pending.svg" alt="Pending" class="legend-icon" />
<span>Pending</span>
</div>
<div class="legend-item">
<img src="/marker-approved.svg" alt="Approved" class="legend-icon" />
<span>Approved</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from 'vue3-leaflet'
import 'leaflet/dist/leaflet.css'
import type { Service } from '~/composables/useServiceMap'
const props = defineProps<{
services?: Service[]
scopeLabel?: string
}>()
const map = ref<any>(null)
const zoom = ref(11)
const center = ref<[number, number]>([47.6333, 19.1333]) // Budapest area
const selectedService = ref<Service | null>(null)
const services = ref<Service[]>(props.services || [])
const getMarkerIcon = (status: string) => {
return status === 'approved' ? '/marker-approved.svg' : '/marker-pending.svg'
}
const openPopup = (service: Service) => {
selectedService.value = service
}
const approveService = (service: Service) => {
console.log('Approving service:', service)
// TODO: Implement API call
service.status = 'approved'
selectedService.value = null
}
const onMapReady = () => {
console.log('Map is ready')
}
onMounted(() => {
// If no services provided, use mock data
if (services.value.length === 0) {
// Mock data will be loaded via composable
}
})
</script>
<style scoped>
.service-map-container {
position: relative;
width: 100%;
height: 100%;
}
.scope-indicator {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
}
.badge {
background-color: #4a90e2;
color: white;
padding: 8px 12px;
border-radius: 20px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.map-wrapper {
border-radius: 8px;
overflow: hidden;
border: 1px solid #ddd;
}
.legend {
position: absolute;
bottom: 20px;
left: 20px;
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-icon {
width: 20px;
height: 20px;
margin-right: 8px;
}
.popup-content {
min-width: 200px;
}
.popup-content h3 {
margin-top: 0;
color: #333;
}
.popup-content p {
margin: 5px 0;
}
.btn-approve {
background-color: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
margin-top: 10px;
width: 100%;
}
.btn-approve:hover {
background-color: #218838;
}
.pending {
color: #3b82f6;
font-weight: bold;
}
.approved {
color: #28a745;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,390 @@
import { ref, computed, onUnmounted } from 'vue'
import { useAuthStore } from '~/stores/auth'
// Types
export interface HealthMetrics {
total_assets: number
total_organizations: number
critical_alerts_24h: number
system_status: 'healthy' | 'degraded' | 'critical'
uptime_percentage: number
response_time_ms: number
database_connections: number
active_users: number
last_updated: string
}
export interface SystemAlert {
id: string
severity: 'info' | 'warning' | 'critical'
title: string
description: string
timestamp: string
component: string
resolved: boolean
}
export interface HealthMonitorState {
metrics: HealthMetrics | null
alerts: SystemAlert[]
loading: boolean
error: string | null
lastUpdated: Date | null
}
// Mock data for development/testing (only for alerts since no backend endpoint yet)
const generateMockMetrics = (): HealthMetrics => {
return {
total_assets: Math.floor(Math.random() * 10000) + 5000,
total_organizations: Math.floor(Math.random() * 500) + 100,
critical_alerts_24h: Math.floor(Math.random() * 10),
system_status: Math.random() > 0.8 ? 'degraded' : Math.random() > 0.95 ? 'critical' : 'healthy',
uptime_percentage: 99.5 + (Math.random() * 0.5 - 0.25), // 99.25% - 99.75%
response_time_ms: Math.floor(Math.random() * 100) + 50,
database_connections: Math.floor(Math.random() * 50) + 10,
active_users: Math.floor(Math.random() * 1000) + 500,
last_updated: new Date().toISOString()
}
}
const generateMockAlerts = (count: number = 5): SystemAlert[] => {
const severities: SystemAlert['severity'][] = ['info', 'warning', 'critical']
const components = ['Database', 'API Gateway', 'Redis', 'PostgreSQL', 'Docker', 'Network', 'Authentication', 'File Storage']
const titles = [
'High memory usage detected',
'Database connection pool exhausted',
'API response time above threshold',
'Redis cache miss rate increased',
'Disk space running low',
'Network latency spike',
'Authentication service slow response',
'Backup job failed'
]
const alerts: SystemAlert[] = []
for (let i = 0; i < count; i++) {
const severity = severities[Math.floor(Math.random() * severities.length)]
const isResolved = Math.random() > 0.7
alerts.push({
id: `alert_${Date.now()}_${i}`,
severity,
title: titles[Math.floor(Math.random() * titles.length)],
description: `Detailed description of the ${severity} alert in the ${components[Math.floor(Math.random() * components.length)]} component.`,
timestamp: new Date(Date.now() - Math.random() * 24 * 60 * 60 * 1000).toISOString(), // Within last 24 hours
component: components[Math.floor(Math.random() * components.length)],
resolved: isResolved
})
}
return alerts
}
// API Service
class HealthMonitorApiService {
private baseUrl = '/api/v1/admin' // Using proxy from nuxt.config.ts
private delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// Get health metrics
async getHealthMetrics(): Promise<HealthMetrics> {
try {
console.log('Fetching health metrics from:', `${this.baseUrl}/health-monitor`)
const response = await fetch(`${this.baseUrl}/health-monitor`, {
headers: this.getAuthHeaders()
})
if (!response.ok) {
const errorText = await response.text()
console.error('Health monitor API error:', response.status, response.statusText, errorText)
// Specific error handling
if (response.status === 401) {
throw new Error('Authentication required. Please log in again.')
} else if (response.status === 403) {
throw new Error('Access forbidden. Admin privileges required.')
} else if (response.status === 500) {
throw new Error('Server error. Please try again later.')
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`)
}
}
const data = await response.json()
console.log('Health monitor API response:', data)
// Transform API response to match HealthMetrics interface
return {
total_assets: data.total_assets || 0,
total_organizations: data.total_organizations || 0,
critical_alerts_24h: data.critical_alerts_24h || 0,
system_status: 'healthy', // Default, API doesn't return this yet
uptime_percentage: 99.9, // Default, API doesn't return this yet
response_time_ms: 50, // Default, API doesn't return this yet
database_connections: 0, // Default, API doesn't return this yet
active_users: data.user_distribution ? Object.values(data.user_distribution).reduce((a: number, b: number) => a + b, 0) : 0,
last_updated: new Date().toISOString()
}
} catch (error) {
console.error('Failed to fetch real health metrics:', error)
throw error // Don't fall back to mock data - let the caller handle it
}
}
// Get system alerts (mocked for now - no backend endpoint)
async getSystemAlerts(options?: {
severity?: SystemAlert['severity']
resolved?: boolean
limit?: number
}): Promise<SystemAlert[]> {
await this.delay(500)
let alerts = generateMockAlerts(10)
if (options?.severity) {
alerts = alerts.filter(alert => alert.severity === options.severity)
}
if (options?.resolved !== undefined) {
alerts = alerts.filter(alert => alert.resolved === options.resolved)
}
if (options?.limit) {
alerts = alerts.slice(0, options.limit)
}
return alerts
}
// Get auth headers (for real API calls)
private getAuthHeaders(): Record<string, string> {
const authStore = useAuthStore()
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (authStore.token) {
headers['Authorization'] = `Bearer ${authStore.token}`
}
// Add geographical scope headers
if (authStore.getScopeId) {
headers['X-Scope-Id'] = authStore.getScopeId.toString()
}
if (authStore.getRegionCode) {
headers['X-Region-Code'] = authStore.getRegionCode
}
if (authStore.getScopeLevel) {
headers['X-Scope-Level'] = authStore.getScopeLevel
}
return headers
}
}
// Composable
export const useHealthMonitor = () => {
const state = ref<HealthMonitorState>({
metrics: null,
alerts: [],
loading: false,
error: null,
lastUpdated: null
})
const apiService = new HealthMonitorApiService()
let refreshInterval: NodeJS.Timeout | null = null
// Computed properties
const systemStatusColor = computed(() => {
if (!state.value.metrics) return 'grey'
switch (state.value.metrics.system_status) {
case 'healthy': return 'green'
case 'degraded': return 'dark-blue' // Changed from orange to dark-blue for better contrast
case 'critical': return 'red'
default: return 'grey'
}
})
const systemStatusIcon = computed(() => {
if (!state.value.metrics) return 'mdi-help-circle'
switch (state.value.metrics.system_status) {
case 'healthy': return 'mdi-check-circle'
case 'degraded': return 'mdi-alert-circle'
case 'critical': return 'mdi-alert-octagon'
default: return 'mdi-help-circle'
}
})
const criticalAlerts = computed(() => {
return state.value.alerts.filter(alert => alert.severity === 'critical' && !alert.resolved)
})
const warningAlerts = computed(() => {
return state.value.alerts.filter(alert => alert.severity === 'warning' && !alert.resolved)
})
const formattedUptime = computed(() => {
if (!state.value.metrics) return 'N/A'
return `${state.value.metrics.uptime_percentage.toFixed(2)}%`
})
const formattedResponseTime = computed(() => {
if (!state.value.metrics) return 'N/A'
return `${state.value.metrics.response_time_ms}ms`
})
// Actions
const fetchHealthMetrics = async () => {
state.value.loading = true
state.value.error = null
try {
const metrics = await apiService.getHealthMetrics()
state.value.metrics = metrics
state.value.lastUpdated = new Date()
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to fetch health metrics'
console.error('Error fetching health metrics:', error)
// NO FALLBACK TO MOCK DATA - let error propagate
} finally {
state.value.loading = false
}
}
const fetchSystemAlerts = async (options?: {
severity?: SystemAlert['severity']
resolved?: boolean
limit?: number
}) => {
state.value.loading = true
state.value.error = null
try {
const alerts = await apiService.getSystemAlerts(options)
state.value.alerts = alerts
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to fetch system alerts'
console.error('Error fetching system alerts:', error)
// Fallback to mock data for alerts (since no real endpoint yet)
state.value.alerts = generateMockAlerts(5)
} finally {
state.value.loading = false
}
}
const refreshAll = async () => {
await Promise.all([
fetchHealthMetrics(),
fetchSystemAlerts()
])
}
const markAlertAsResolved = async (alertId: string) => {
// In a real implementation, this would call an API endpoint
// await apiService.resolveAlert(alertId)
// Update local state
const alertIndex = state.value.alerts.findIndex(alert => alert.id === alertId)
if (alertIndex !== -1) {
state.value.alerts[alertIndex].resolved = true
}
}
const dismissAlert = (alertId: string) => {
// Remove alert from local state (frontend only)
state.value.alerts = state.value.alerts.filter(alert => alert.id !== alertId)
}
// Start automatic refresh (30-second interval)
const startPolling = (intervalMs: number = 30000) => {
stopPolling() // Clear any existing interval
refreshInterval = setInterval(() => {
console.log('Auto-refreshing health monitor data...')
refreshAll()
}, intervalMs)
console.log(`Health monitor polling started with ${intervalMs}ms interval`)
}
// Stop automatic refresh
const stopPolling = () => {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
console.log('Health monitor polling stopped')
}
}
// Initialize with polling
const initialize = (enablePolling: boolean = true) => {
refreshAll()
if (enablePolling) {
startPolling()
}
}
// Cleanup on unmount
onUnmounted(() => {
stopPolling()
})
return {
// State
state: computed(() => state.value),
metrics: computed(() => state.value.metrics),
alerts: computed(() => state.value.alerts),
loading: computed(() => state.value.loading),
error: computed(() => state.value.error),
lastUpdated: computed(() => state.value.lastUpdated),
// Computed
systemStatusColor,
systemStatusIcon,
criticalAlerts,
warningAlerts,
formattedUptime,
formattedResponseTime,
// Actions
fetchHealthMetrics,
fetchSystemAlerts,
refreshAll,
markAlertAsResolved,
dismissAlert,
initialize,
startPolling,
stopPolling,
// Helper functions
getAlertColor: (severity: SystemAlert['severity']) => {
switch (severity) {
case 'info': return 'blue'
case 'warning': return 'dark-blue' // Changed from orange to dark-blue
case 'critical': return 'red'
default: return 'grey'
}
},
getAlertIcon: (severity: SystemAlert['severity']) => {
switch (severity) {
case 'info': return 'mdi-information'
case 'warning': return 'mdi-alert'
case 'critical': return 'mdi-alert-circle'
default: return 'mdi-help-circle'
}
},
formatTimestamp: (timestamp: string) => {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
}
}

View File

@@ -0,0 +1,200 @@
import { ref, onMounted, onUnmounted } from 'vue'
export interface PollingOptions {
interval?: number // milliseconds
immediate?: boolean // whether to execute immediately on start
maxRetries?: number // maximum number of retries on error
retryDelay?: number // delay between retries in milliseconds
onError?: (error: Error) => void // error handler
}
export interface PollingState {
isPolling: boolean
isFetching: boolean
error: string | null
retryCount: number
lastFetchTime: Date | null
}
/**
* Composable for implementing polling/real-time updates
*
* @param callback - Function to execute on each poll
* @param options - Polling configuration options
* @returns Polling controls and state
*/
export const usePolling = <T>(
callback: () => Promise<T> | T,
options: PollingOptions = {}
) => {
const {
interval = 3000, // 3 seconds default
immediate = true,
maxRetries = 3,
retryDelay = 1000,
onError
} = options
// State
const state = ref<PollingState>({
isPolling: false,
isFetching: false,
error: null,
retryCount: 0,
lastFetchTime: null
})
// Polling interval reference
let pollInterval: NodeJS.Timeout | null = null
let retryTimeout: NodeJS.Timeout | null = null
// Execute the polling callback
const executePoll = async (): Promise<T | null> => {
if (state.value.isFetching) {
return null // Skip if already fetching
}
state.value.isFetching = true
state.value.error = null
try {
const result = await callback()
state.value.lastFetchTime = new Date()
state.value.retryCount = 0 // Reset retry count on success
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
state.value.error = errorMessage
state.value.retryCount++
// Call error handler if provided
if (onError) {
onError(error instanceof Error ? error : new Error(errorMessage))
}
// Handle retries
if (state.value.retryCount <= maxRetries) {
console.warn(`Polling error (retry ${state.value.retryCount}/${maxRetries}):`, errorMessage)
// Schedule retry
if (retryTimeout) {
clearTimeout(retryTimeout)
}
retryTimeout = setTimeout(() => {
executePoll()
}, retryDelay)
} else {
console.error(`Polling failed after ${maxRetries} retries:`, errorMessage)
stopPolling() // Stop polling after max retries
}
return null
} finally {
state.value.isFetching = false
}
}
// Start polling
const startPolling = () => {
if (state.value.isPolling) {
return // Already polling
}
state.value.isPolling = true
state.value.error = null
// Execute immediately if requested
if (immediate) {
executePoll()
}
// Set up interval
pollInterval = setInterval(() => {
executePoll()
}, interval)
}
// Stop polling
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeout = null
}
state.value.isPolling = false
state.value.isFetching = false
}
// Toggle polling
const togglePolling = () => {
if (state.value.isPolling) {
stopPolling()
} else {
startPolling()
}
}
// Force immediate execution
const forcePoll = async (): Promise<T | null> => {
return await executePoll()
}
// Update polling interval
const updateInterval = (newInterval: number) => {
const wasPolling = state.value.isPolling
if (wasPolling) {
stopPolling()
}
// Update interval in options (for next start)
options.interval = newInterval
if (wasPolling) {
startPolling()
}
}
// Cleanup on unmount
onUnmounted(() => {
stopPolling()
})
// Auto-start on mount if immediate is true
onMounted(() => {
if (immediate) {
startPolling()
}
})
return {
// State
state: state.value,
isPolling: state.value.isPolling,
isFetching: state.value.isFetching,
error: state.value.error,
retryCount: state.value.retryCount,
lastFetchTime: state.value.lastFetchTime,
// Controls
startPolling,
stopPolling,
togglePolling,
forcePoll,
updateInterval,
// Helper
resetError: () => {
state.value.error = null
state.value.retryCount = 0
}
}
}
export default usePolling

View File

@@ -0,0 +1,237 @@
import { useAuthStore } from '~/stores/auth'
// Role definitions with hierarchical ranks
export enum Role {
SUPERADMIN = 'superadmin',
ADMIN = 'admin',
MODERATOR = 'moderator',
SALESPERSON = 'salesperson'
}
// Scope level definitions
export enum ScopeLevel {
GLOBAL = 'global',
COUNTRY = 'country',
REGION = 'region',
CITY = 'city',
DISTRICT = 'district'
}
// Role rank mapping (higher number = higher authority)
export const RoleRank: Record<Role, number> = {
[Role.SUPERADMIN]: 10,
[Role.ADMIN]: 7,
[Role.MODERATOR]: 5,
[Role.SALESPERSON]: 3
}
// Tile permissions mapping
export interface TilePermission {
id: string
title: string
description: string
requiredRole: Role[]
minRank?: number
requiredPermission?: string
scopeLevel?: ScopeLevel[]
}
// Available tiles with RBAC requirements
export const AdminTiles: TilePermission[] = [
{
id: 'ai-logs',
title: 'AI Logs Monitor',
description: 'Real-time tracking of AI robot pipelines',
requiredRole: [Role.SUPERADMIN, Role.ADMIN, Role.MODERATOR],
minRank: 5,
requiredPermission: 'view:dashboard'
},
{
id: 'financial-dashboard',
title: 'Financial Dashboard',
description: 'Revenue, expenses, ROI metrics with geographical filtering',
requiredRole: [Role.SUPERADMIN, Role.ADMIN],
minRank: 7,
requiredPermission: 'view:finance',
scopeLevel: [ScopeLevel.GLOBAL, ScopeLevel.COUNTRY, ScopeLevel.REGION]
},
{
id: 'salesperson-hub',
title: 'Salesperson Hub',
description: 'Performance metrics, leads, conversions for sales teams',
requiredRole: [Role.SUPERADMIN, Role.ADMIN, Role.SALESPERSON],
minRank: 3,
requiredPermission: 'view:sales'
},
{
id: 'user-management',
title: 'User Management',
description: 'Active users, registration trends, moderation queue',
requiredRole: [Role.SUPERADMIN, Role.ADMIN, Role.MODERATOR],
minRank: 5,
requiredPermission: 'view:users',
scopeLevel: [ScopeLevel.GLOBAL, ScopeLevel.COUNTRY, ScopeLevel.REGION, ScopeLevel.CITY]
},
{
id: 'service-moderation-map',
title: 'Service Moderation Map',
description: 'Geographical view of pending/flagged services',
requiredRole: [Role.SUPERADMIN, Role.ADMIN, Role.MODERATOR],
minRank: 5,
requiredPermission: 'moderate:services',
scopeLevel: [ScopeLevel.CITY, ScopeLevel.DISTRICT]
},
{
id: 'gamification-control',
title: 'Gamification Control',
description: 'XP levels, badges, penalty system administration',
requiredRole: [Role.SUPERADMIN, Role.ADMIN],
minRank: 7,
requiredPermission: 'manage:settings'
},
{
id: 'system-health',
title: 'System Health',
description: 'API status, database metrics, uptime monitoring',
requiredRole: [Role.SUPERADMIN, Role.ADMIN],
minRank: 7,
requiredPermission: 'view:dashboard'
}
]
// Composable for RBAC checks
export function useRBAC() {
const authStore = useAuthStore()
// Check if user can access a specific tile
function canAccessTile(tileId: string): boolean {
const tile = AdminTiles.find(t => t.id === tileId)
if (!tile) return false
// Check role
if (!tile.requiredRole.includes(authStore.getUserRole as Role)) {
return false
}
// Check rank
if (tile.minRank && !authStore.hasRank(tile.minRank)) {
return false
}
// Check permission
if (tile.requiredPermission && !authStore.hasPermission(tile.requiredPermission)) {
return false
}
// Check scope level
if (tile.scopeLevel && tile.scopeLevel.length > 0) {
const userScopeLevel = authStore.getScopeLevel as ScopeLevel
if (!tile.scopeLevel.includes(userScopeLevel)) {
return false
}
}
return true
}
// Get filtered tiles for current user
function getFilteredTiles(): TilePermission[] {
return AdminTiles.filter(tile => canAccessTile(tile.id))
}
// Check if user can perform action
function canPerformAction(permission: string, minRank?: number): boolean {
if (!authStore.hasPermission(permission)) {
return false
}
if (minRank && !authStore.hasRank(minRank)) {
return false
}
return true
}
// Check if user can access scope
function canAccessScope(scopeLevel: ScopeLevel, scopeId?: number, regionCode?: string): boolean {
const userScopeLevel = authStore.getScopeLevel as ScopeLevel
// Superadmin can access everything
if (authStore.getUserRole === Role.SUPERADMIN) {
return true
}
// Check scope level hierarchy
const scopeHierarchy = [
ScopeLevel.GLOBAL,
ScopeLevel.COUNTRY,
ScopeLevel.REGION,
ScopeLevel.CITY,
ScopeLevel.DISTRICT
]
const userLevelIndex = scopeHierarchy.indexOf(userScopeLevel)
const requestedLevelIndex = scopeHierarchy.indexOf(scopeLevel)
// User can only access their level or lower (more specific) levels
if (requestedLevelIndex < userLevelIndex) {
return false
}
// Check specific scope ID or region code if provided
if (scopeId || regionCode) {
return authStore.canAccessScope(scopeId || 0, regionCode)
}
return true
}
// Get user's accessible scope levels
function getAccessibleScopeLevels(): ScopeLevel[] {
const userScopeLevel = authStore.getScopeLevel as ScopeLevel
const scopeHierarchy = [
ScopeLevel.GLOBAL,
ScopeLevel.COUNTRY,
ScopeLevel.REGION,
ScopeLevel.CITY,
ScopeLevel.DISTRICT
]
const userLevelIndex = scopeHierarchy.indexOf(userScopeLevel)
return scopeHierarchy.slice(userLevelIndex)
}
// Get role color for UI
function getRoleColor(role?: string): string {
const userRole = role || authStore.getUserRole
switch (userRole) {
case Role.SUPERADMIN:
return 'purple'
case Role.ADMIN:
return 'blue'
case Role.MODERATOR:
return 'green'
case Role.SALESPERSON:
return 'orange'
default:
return 'gray'
}
}
return {
// Data
Role,
ScopeLevel,
RoleRank,
AdminTiles,
// Functions
canAccessTile,
getFilteredTiles,
canPerformAction,
canAccessScope,
getAccessibleScopeLevels,
getRoleColor
}
}

View File

@@ -0,0 +1,185 @@
import { ref, computed } from 'vue'
export interface Service {
id: number
name: string
lat: number
lng: number
status: 'pending' | 'approved'
address: string
distance: number
category: string
}
export interface Scope {
id: string
label: string
bounds: [[number, number], [number, number]] // SW, NE corners
}
export const useServiceMap = () => {
// Mock services around Budapest
const services = ref<Service[]>([
{
id: 1,
name: 'AutoService Budapest',
lat: 47.6333,
lng: 19.1333,
status: 'pending',
address: 'Budapest, Kossuth Lajos utca 12',
distance: 0.5,
category: 'Car Repair'
},
{
id: 2,
name: 'MOL Station',
lat: 47.6400,
lng: 19.1400,
status: 'approved',
address: 'Budapest, Váci út 45',
distance: 1.2,
category: 'Fuel Station'
},
{
id: 3,
name: 'TireMaster',
lat: 47.6200,
lng: 19.1200,
status: 'pending',
address: 'Budapest, Üllői út 78',
distance: 2.1,
category: 'Tire Service'
},
{
id: 4,
name: 'CarWash Express',
lat: 47.6500,
lng: 19.1500,
status: 'approved',
address: 'Budapest, Róna utca 5',
distance: 3.0,
category: 'Car Wash'
},
{
id: 5,
name: 'BrakeCenter',
lat: 47.6100,
lng: 19.1100,
status: 'pending',
address: 'Budapest, Könyves Kálmán körút 32',
distance: 2.5,
category: 'Brake Service'
},
{
id: 6,
name: 'ElectricCar Service',
lat: 47.6000,
lng: 19.1000,
status: 'pending',
address: 'Budapest, Hungária körút 120',
distance: 4.2,
category: 'EV Charging'
},
{
id: 7,
name: 'OilChange Pro',
lat: 47.6700,
lng: 19.1700,
status: 'approved',
address: 'Budapest, Szentmihályi út 67',
distance: 5.1,
category: 'Oil Change'
},
{
id: 8,
name: 'BodyShop Elite',
lat: 47.5900,
lng: 19.0900,
status: 'pending',
address: 'Budapest, Gyáli út 44',
distance: 5.8,
category: 'Body Repair'
}
])
// Simulated RBAC geographical scope
const currentScope = ref<Scope>({
id: 'pest_county',
label: 'Pest County / Central Hungary',
bounds: [[47.3, 18.9], [47.8, 19.5]]
})
const scopeLabel = computed(() => currentScope.value.label)
const pendingServices = computed(() =>
services.value.filter(s => s.status === 'pending')
)
const approvedServices = computed(() =>
services.value.filter(s => s.status === 'approved')
)
const approveService = (serviceId: number) => {
const service = services.value.find(s => s.id === serviceId)
if (service) {
service.status = 'approved'
console.log(`Service ${serviceId} approved`)
}
}
const addMockService = (service: Omit<Service, 'id'>) => {
const newId = Math.max(...services.value.map(s => s.id)) + 1
services.value.push({
id: newId,
...service
})
}
const filterByScope = (servicesList: Service[]) => {
const [sw, ne] = currentScope.value.bounds
return servicesList.filter(s =>
s.lat >= sw[0] && s.lat <= ne[0] &&
s.lng >= sw[1] && s.lng <= ne[1]
)
}
const servicesInScope = computed(() =>
filterByScope(services.value)
)
const changeScope = (scope: Scope) => {
currentScope.value = scope
}
// Available scopes for simulation
const availableScopes: Scope[] = [
{
id: 'budapest',
label: 'Budapest Only',
bounds: [[47.4, 19.0], [47.6, 19.3]]
},
{
id: 'pest_county',
label: 'Pest County / Central Hungary',
bounds: [[47.3, 18.9], [47.8, 19.5]]
},
{
id: 'hungary',
label: 'Whole Hungary',
bounds: [[45.7, 16.1], [48.6, 22.9]]
}
]
return {
services,
pendingServices,
approvedServices,
scopeLabel,
currentScope,
servicesInScope,
approveService,
addMockService,
changeScope,
availableScopes
}
}

View File

@@ -0,0 +1,498 @@
import { ref, computed } from 'vue'
import { useAuthStore } from '~/stores/auth'
// Types
export interface User {
id: number
email: string
role: 'superadmin' | 'admin' | 'moderator' | 'sales_agent'
scope_level: 'Global' | 'Country' | 'Region' | 'City' | 'District'
status: 'active' | 'inactive'
created_at: string
updated_at?: string
last_login?: string
organization_id?: number
region_code?: string
country_code?: string
}
export interface UpdateUserRoleRequest {
role: User['role']
scope_level: User['scope_level']
scope_id?: number
region_code?: string
country_code?: string
}
export interface UserManagementState {
users: User[]
loading: boolean
error: string | null
}
// Geographical scope definitions for mock data
const geographicalScopes = [
// Hungary hierarchy
{ level: 'Country' as const, code: 'HU', name: 'Hungary', region_code: null },
{ level: 'Region' as const, code: 'HU-PE', name: 'Pest County', country_code: 'HU' },
{ level: 'City' as const, code: 'HU-BU', name: 'Budapest', country_code: 'HU', region_code: 'HU-PE' },
{ level: 'District' as const, code: 'HU-BU-05', name: 'District V', country_code: 'HU', region_code: 'HU-BU' },
// Germany hierarchy
{ level: 'Country' as const, code: 'DE', name: 'Germany', region_code: null },
{ level: 'Region' as const, code: 'DE-BE', name: 'Berlin', country_code: 'DE' },
{ level: 'City' as const, code: 'DE-BER', name: 'Berlin', country_code: 'DE', region_code: 'DE-BE' },
// UK hierarchy
{ level: 'Country' as const, code: 'GB', name: 'United Kingdom', region_code: null },
{ level: 'Region' as const, code: 'GB-LON', name: 'London', country_code: 'GB' },
{ level: 'City' as const, code: 'GB-LND', name: 'London', country_code: 'GB', region_code: 'GB-LON' },
]
// Mock data generator with consistent geographical scopes
const generateMockUsers = (count: number = 25): User[] => {
const roles: User['role'][] = ['superadmin', 'admin', 'moderator', 'sales_agent']
const statuses: User['status'][] = ['active', 'inactive']
const domains = ['servicefinder.com', 'example.com', 'partner.com', 'customer.org']
const firstNames = ['John', 'Jane', 'Robert', 'Emily', 'Michael', 'Sarah', 'David', 'Lisa', 'James', 'Maria']
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez']
const users: User[] = []
// Predefined users with specific geographical scopes for testing
const predefinedUsers: Partial<User>[] = [
// Global superadmin
{ email: 'superadmin@servicefinder.com', role: 'superadmin', scope_level: 'Global', country_code: undefined, region_code: undefined },
// Hungary admin
{ email: 'admin.hu@servicefinder.com', role: 'admin', scope_level: 'Country', country_code: 'HU', region_code: undefined },
// Pest County moderator
{ email: 'moderator.pest@servicefinder.com', role: 'moderator', scope_level: 'Region', country_code: 'HU', region_code: 'HU-PE' },
// Budapest sales agent
{ email: 'sales.budapest@servicefinder.com', role: 'sales_agent', scope_level: 'City', country_code: 'HU', region_code: 'HU-BU' },
// District V sales agent
{ email: 'agent.district5@servicefinder.com', role: 'sales_agent', scope_level: 'District', country_code: 'HU', region_code: 'HU-BU-05' },
// Germany admin
{ email: 'admin.de@servicefinder.com', role: 'admin', scope_level: 'Country', country_code: 'DE', region_code: undefined },
// Berlin moderator
{ email: 'moderator.berlin@servicefinder.com', role: 'moderator', scope_level: 'City', country_code: 'DE', region_code: 'DE-BE' },
// UK admin
{ email: 'admin.uk@servicefinder.com', role: 'admin', scope_level: 'Country', country_code: 'GB', region_code: undefined },
// London sales agent
{ email: 'sales.london@servicefinder.com', role: 'sales_agent', scope_level: 'City', country_code: 'GB', region_code: 'GB-LON' },
]
// Add predefined users
predefinedUsers.forEach((userData, index) => {
users.push({
id: index + 1,
email: userData.email!,
role: userData.role!,
scope_level: userData.scope_level!,
status: 'active',
created_at: `2026-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
updated_at: `2026-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
last_login: `2026-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
organization_id: Math.floor(Math.random() * 10) + 1,
country_code: userData.country_code,
region_code: userData.region_code,
})
})
// Generate remaining random users
for (let i = users.length + 1; i <= count; i++) {
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)]
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)]
const domain = domains[Math.floor(Math.random() * domains.length)]
const role = roles[Math.floor(Math.random() * roles.length)]
const status = statuses[Math.floor(Math.random() * statuses.length)]
// Select a random geographical scope
const scope = geographicalScopes[Math.floor(Math.random() * geographicalScopes.length)]
users.push({
id: i,
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${domain}`,
role,
scope_level: scope.level,
status,
created_at: `2026-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
updated_at: `2026-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
last_login: `2026-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
organization_id: Math.floor(Math.random() * 10) + 1,
country_code: scope.country_code || undefined,
region_code: scope.region_code || undefined,
})
}
return users
}
// API Service (Mock implementation)
class UserManagementApiService {
private mockUsers: User[] = generateMockUsers(15)
private delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// Get all users (with optional filtering)
async getUsers(options?: {
role?: User['role']
scope_level?: User['scope_level']
status?: User['status']
search?: string
country_code?: string
region_code?: string
geographical_scope?: 'Global' | 'Hungary' | 'Pest County' | 'Budapest' | 'District V'
}): Promise<User[]> {
await this.delay(500) // Simulate network delay
let filteredUsers = [...this.mockUsers]
if (options?.role) {
filteredUsers = filteredUsers.filter(user => user.role === options.role)
}
if (options?.scope_level) {
filteredUsers = filteredUsers.filter(user => user.scope_level === options.scope_level)
}
if (options?.status) {
filteredUsers = filteredUsers.filter(user => user.status === options.status)
}
if (options?.country_code) {
filteredUsers = filteredUsers.filter(user =>
user.country_code === options.country_code || user.scope_level === 'Global'
)
}
if (options?.region_code) {
filteredUsers = filteredUsers.filter(user =>
user.region_code === options.region_code ||
user.scope_level === 'Global' ||
(user.scope_level === 'Country' && user.country_code === options.country_code)
)
}
// Geographical scope filtering (simplified for demo)
if (options?.geographical_scope) {
switch (options.geographical_scope) {
case 'Global':
// All users
break
case 'Hungary':
filteredUsers = filteredUsers.filter(user =>
user.country_code === 'HU' || user.scope_level === 'Global'
)
break
case 'Pest County':
filteredUsers = filteredUsers.filter(user =>
user.region_code === 'HU-PE' ||
user.country_code === 'HU' ||
user.scope_level === 'Global'
)
break
case 'Budapest':
filteredUsers = filteredUsers.filter(user =>
user.region_code === 'HU-BU' ||
user.region_code === 'HU-PE' ||
user.country_code === 'HU' ||
user.scope_level === 'Global'
)
break
case 'District V':
filteredUsers = filteredUsers.filter(user =>
user.region_code === 'HU-BU-05' ||
user.region_code === 'HU-BU' ||
user.region_code === 'HU-PE' ||
user.country_code === 'HU' ||
user.scope_level === 'Global'
)
break
}
}
if (options?.search) {
const searchLower = options.search.toLowerCase()
filteredUsers = filteredUsers.filter(user =>
user.email.toLowerCase().includes(searchLower) ||
user.role.toLowerCase().includes(searchLower) ||
user.scope_level.toLowerCase().includes(searchLower) ||
(user.country_code && user.country_code.toLowerCase().includes(searchLower)) ||
(user.region_code && user.region_code.toLowerCase().includes(searchLower))
)
}
return filteredUsers
}
// Get single user by ID
async getUserById(id: number): Promise<User | null> {
await this.delay(300)
return this.mockUsers.find(user => user.id === id) || null
}
// Update user role and scope
async updateUserRole(id: number, data: UpdateUserRoleRequest): Promise<User> {
await this.delay(800) // Simulate slower update
const userIndex = this.mockUsers.findIndex(user => user.id === id)
if (userIndex === -1) {
throw new Error(`User with ID ${id} not found`)
}
// Check permissions (in a real app, this would be server-side)
const authStore = useAuthStore()
const currentUserRole = authStore.getUserRole
// Superadmin can update anyone
// Admin cannot update superadmin or other admins
if (currentUserRole === 'admin') {
const targetUser = this.mockUsers[userIndex]
if (targetUser.role === 'superadmin' || (targetUser.role === 'admin' && targetUser.id !== authStore.getUserId)) {
throw new Error('Admin cannot update superadmin or other admin users')
}
}
// Update the user
const updatedUser: User = {
...this.mockUsers[userIndex],
...data,
updated_at: new Date().toISOString().split('T')[0],
}
this.mockUsers[userIndex] = updatedUser
return updatedUser
}
// Toggle user status
async toggleUserStatus(id: number): Promise<User> {
await this.delay(500)
const userIndex = this.mockUsers.findIndex(user => user.id === id)
if (userIndex === -1) {
throw new Error(`User with ID ${id} not found`)
}
const currentStatus = this.mockUsers[userIndex].status
const newStatus: User['status'] = currentStatus === 'active' ? 'inactive' : 'active'
this.mockUsers[userIndex] = {
...this.mockUsers[userIndex],
status: newStatus,
updated_at: new Date().toISOString().split('T')[0],
}
return this.mockUsers[userIndex]
}
// Create new user (mock)
async createUser(email: string, role: User['role'], scope_level: User['scope_level']): Promise<User> {
await this.delay(1000)
const newUser: User = {
id: Math.max(...this.mockUsers.map(u => u.id)) + 1,
email,
role,
scope_level,
status: 'active',
created_at: new Date().toISOString().split('T')[0],
updated_at: new Date().toISOString().split('T')[0],
}
this.mockUsers.push(newUser)
return newUser
}
// Delete user (mock - just deactivate)
async deleteUser(id: number): Promise<void> {
await this.delay(700)
const userIndex = this.mockUsers.findIndex(user => user.id === id)
if (userIndex === -1) {
throw new Error(`User with ID ${id} not found`)
}
// Instead of deleting, mark as inactive
this.mockUsers[userIndex] = {
...this.mockUsers[userIndex],
status: 'inactive',
updated_at: new Date().toISOString().split('T')[0],
}
}
}
// Composable
export const useUserManagement = () => {
const state = ref<UserManagementState>({
users: [],
loading: false,
error: null,
})
const apiService = new UserManagementApiService()
// Computed
const activeUsers = computed(() => state.value.users.filter(user => user.status === 'active'))
const inactiveUsers = computed(() => state.value.users.filter(user => user.status === 'inactive'))
const superadminUsers = computed(() => state.value.users.filter(user => user.role === 'superadmin'))
const adminUsers = computed(() => state.value.users.filter(user => user.role === 'admin'))
// Actions
const fetchUsers = async (options?: {
role?: User['role']
scope_level?: User['scope_level']
status?: User['status']
search?: string
country_code?: string
region_code?: string
geographical_scope?: 'Global' | 'Hungary' | 'Pest County' | 'Budapest' | 'District V'
}) => {
state.value.loading = true
state.value.error = null
try {
const users = await apiService.getUsers(options)
state.value.users = users
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to fetch users'
console.error('Error fetching users:', error)
} finally {
state.value.loading = false
}
}
const updateUserRole = async (id: number, data: UpdateUserRoleRequest) => {
state.value.loading = true
state.value.error = null
try {
const updatedUser = await apiService.updateUserRole(id, data)
// Update local state
const userIndex = state.value.users.findIndex(user => user.id === id)
if (userIndex !== -1) {
state.value.users[userIndex] = updatedUser
}
return updatedUser
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to update user role'
console.error('Error updating user role:', error)
throw error
} finally {
state.value.loading = false
}
}
const toggleUserStatus = async (id: number) => {
state.value.loading = true
state.value.error = null
try {
const updatedUser = await apiService.toggleUserStatus(id)
// Update local state
const userIndex = state.value.users.findIndex(user => user.id === id)
if (userIndex !== -1) {
state.value.users[userIndex] = updatedUser
}
return updatedUser
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to toggle user status'
console.error('Error toggling user status:', error)
throw error
} finally {
state.value.loading = false
}
}
const createUser = async (email: string, role: User['role'], scope_level: User['scope_level']) => {
state.value.loading = true
state.value.error = null
try {
const newUser = await apiService.createUser(email, role, scope_level)
state.value.users.push(newUser)
return newUser
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to create user'
console.error('Error creating user:', error)
throw error
} finally {
state.value.loading = false
}
}
const deleteUser = async (id: number) => {
state.value.loading = true
state.value.error = null
try {
await apiService.deleteUser(id)
// Update local state (mark as inactive)
const userIndex = state.value.users.findIndex(user => user.id === id)
if (userIndex !== -1) {
state.value.users[userIndex] = {
...state.value.users[userIndex],
status: 'inactive',
updated_at: new Date().toISOString().split('T')[0],
}
}
} catch (error) {
state.value.error = error instanceof Error ? error.message : 'Failed to delete user'
console.error('Error deleting user:', error)
throw error
} finally {
state.value.loading = false
}
}
// Initialize with some data
const initialize = () => {
fetchUsers()
}
// Helper function to get geographical scopes for UI
const getGeographicalScopes = () => {
return [
{ value: 'Global', label: 'Global', icon: 'mdi-earth', description: 'All users worldwide' },
{ value: 'Hungary', label: 'Hungary', icon: 'mdi-flag', description: 'Users in Hungary' },
{ value: 'Pest County', label: 'Pest County', icon: 'mdi-map-marker-radius', description: 'Users in Pest County' },
{ value: 'Budapest', label: 'Budapest', icon: 'mdi-city', description: 'Users in Budapest' },
{ value: 'District V', label: 'District V', icon: 'mdi-map-marker', description: 'Users in District V' },
]
}
return {
// State
state: computed(() => state.value),
users: computed(() => state.value.users),
loading: computed(() => state.value.loading),
error: computed(() => state.value.error),
// Computed
activeUsers,
inactiveUsers,
superadminUsers,
adminUsers,
// Actions
fetchUsers,
updateUserRole,
toggleUserStatus,
createUser,
deleteUser,
initialize,
// Helper functions
getUserById: (id: number) => state.value.users.find(user => user.id === id),
filterByRole: (role: User['role']) => state.value.users.filter(user => user.role === role),
filterByScope: (scope_level: User['scope_level']) => state.value.users.filter(user => user.scope_level === scope_level),
getGeographicalScopes,
}
}
export default useUserManagement

View File

@@ -0,0 +1,356 @@
# Epic 10 - Mission Control Admin Frontend Development Log
## Project Overview
**Date:** 2026-03-23
**Phase:** 1 & 2 Implementation
**Status:** In Development
**Target:** Complete Admin Dashboard with RBAC and Launchpad
## Architectural Decisions
### 1. Technology Stack Selection
- **Framework:** Nuxt 3 (SSR/SPA hybrid) - Chosen for its file-based routing, SEO capabilities, and Vue 3 integration
- **UI Library:** Vuetify 3 - Material Design implementation that provides consistent components and theming
- **State Management:** Pinia - Vue 3's official state management, lightweight and TypeScript friendly
- **TypeScript:** Strict mode enabled for type safety and better developer experience
- **Build Tool:** Vite (via Nuxt) - Fast builds and hot module replacement
### 2. Project Structure
```
frontend/admin/
├── components/ # Reusable Vue components
├── composables/ # Vue composables (useRBAC, etc.)
├── middleware/ # Nuxt middleware (auth.global.ts)
├── pages/ # File-based routes
├── stores/ # Pinia stores (auth.ts, tiles.ts)
├── app.vue # Root component
├── nuxt.config.ts # Nuxt configuration
├── tsconfig.json # TypeScript configuration
└── Dockerfile # Containerization
```
### 3. Authentication & RBAC Architecture
#### JWT Token Structure
Tokens from backend FastAPI `/login` endpoint must include:
```json
{
"sub": "user@email.com",
"role": "admin",
"rank": 7,
"scope_level": "region",
"region_code": "HU-BU",
"scope_id": 123,
"exp": 1700000000,
"iat": 1700000000
}
```
#### Pinia Auth Store Features
- Token parsing and validation with `jwt-decode`
- Automatic token refresh detection
- Role-based permission generation
- Geographical scope validation
- LocalStorage persistence for session continuity
#### RBAC Implementation
- **Role Hierarchy:** Superadmin (10) > Admin (7) > Moderator (5) > Salesperson (3)
- **Scope Levels:** Global > Country > Region > City > District
- **Permission System:** Dynamic permission generation based on role and rank
- **Tile Visibility:** Tiles filtered by role, rank, and scope level
### 4. Middleware Strategy
- **Global Auth Middleware:** Runs on every route change
- **Public Routes:** `/login`, `/forgot-password`, `/reset-password`
- **Role Validation:** Route meta validation with `requiredRole`, `minRank`, `requiredPermission`
- **Scope Validation:** Geographical scope checking with `requiredScopeId` and `requiredRegionCode`
- **Automatic Header Injection:** Adds auth and scope headers to all API requests
### 5. Launchpad Tile System
#### Tile Definition
Each tile includes:
- Unique ID and display title
- Required roles and minimum rank
- Required permissions
- Applicable scope levels
- Icon and color mapping
#### Dynamic Filtering
- Tiles filtered in real-time based on user's RBAC attributes
- Empty state when no tiles accessible
- Visual indicators for access level (role badges, rank chips)
#### User Customization
- Per-user tile preferences stored in localStorage
- Position persistence for drag-and-drop reordering
- Visibility toggles for personalized dashboards
- Size customization (small, medium, large)
### 6. Component Design
#### TileCard Component
- Responsive card with hover effects
- Role and scope level badges
- Color-coded by tile type
- Click-to-navigate functionality
- Consistent sizing and spacing
#### Dashboard Layout
- App bar with user menu and role indicators
- Navigation drawer for main sections
- Welcome header with user context
- Grid-based tile layout
- Quick stats section for at-a-glance metrics
- Footer with system status
### 7. Docker Configuration
#### Multi-stage Build
1. **Builder Stage:** Node 20 with full dev dependencies
2. **Runner Stage:** Optimized production image with non-root user
#### Port Configuration
- **Internal:** 3000 (Nuxt default)
- **External:** 8502 (mapped in docker-compose.yml)
- **API Proxy:** `NUXT_PUBLIC_API_BASE_URL=http://sf_api:8000`
#### Volume Strategy
- Development: Hot-reload with mounted source code
- Production: Built assets only for smaller image size
### 8. API Integration Strategy
#### Headers Injection
All authenticated requests automatically include:
- `Authorization: Bearer <token>`
- `X-Scope-Id: <scope_id>`
- `X-Region-Code: <region_code>`
- `X-Scope-Level: <scope_level>`
#### Error Handling
- Token expiration detection and auto-logout
- Permission denied redirects to `/unauthorized`
- Network error handling with user feedback
### 9. Security Considerations
#### Client-side Security
- No sensitive data in client-side code
- Token storage in localStorage with expiration checks
- Role validation on both client and server
- XSS protection through Vue's template system
#### Geographical Isolation
- Scope validation before data display
- Region-based data filtering at API level
- Visual indicators for current scope context
### 10. Performance Optimizations
#### Code Splitting
- Route-based code splitting via Nuxt
- Component lazy loading where appropriate
- Vendor chunk optimization
#### Asset Optimization
- Vuetify tree-shaking in production
- CSS purging for unused styles
- Image optimization pipeline
#### Caching Strategy
- LocalStorage for user preferences
- Token validation caching
- Tile configuration caching per session
## Implementation Status
### ✅ Completed Phase 1
1. Project initialization with Nuxt 3 + Vuetify 3
2. Docker configuration and docker-compose integration
3. Pinia auth store with JWT parsing
4. Global authentication middleware
5. RBAC composable with role/scope validation
6. Basic dashboard layout
### ✅ Completed Phase 2
1. Launchpad tile system with dynamic filtering
2. TileCard component with role-based styling
3. User preference store for tile customization
4. Geographical scope integration
5. Complete dashboard with stats and navigation
### 🔄 Pending for Phase 3
1. Geographical map integration (Leaflet/Vue3-leaflet)
2. Individual tile pages (AI Logs, Finance, Users, etc.)
3. User management interface
4. Real-time data updates
5. Comprehensive testing suite
## Known Issues & TODOs
### Immediate TODOs
1. Install dependencies (`npm install` in container)
2. Create login page component
3. Implement API service with axios interceptors
4. Add error boundary components
5. Create unauthorized/404 pages
### Technical Debt
1. TypeScript strict mode configuration needs refinement
2. Vuetify theme customization for brand colors
3. Internationalization (i18n) setup
4. E2E testing with Cypress
5. Performance benchmarking
## Deployment Notes
### Environment Variables
```bash
NUXT_PUBLIC_API_BASE_URL=http://localhost:8000
NODE_ENV=production
NUXT_HOST=0.0.0.0
NUXT_PORT=3000
```
### Build Commands
```bash
# Development
npm run dev
# Production build
npm run build
npm run preview
# Docker build
docker build -t sf-admin-frontend .
```
### Health Checks
- `/api/health` endpoint for container health checks
- Docker HEALTHCHECK directive in Dockerfile
- Log aggregation for monitoring
## Ticket #113: RBAC Implementation & Role Management System
**Date:** 2026-03-23
**Status:** In Progress
**Assigned:** Fast Coder
**Gitea Issue:** #113
### Task Breakdown
#### Task 1: User Management Interface (RBAC Admin)
1. Create `/users` page accessible only by Superadmin and Admin ranks
2. Build Vuetify Data Table with columns: Email, Current Role, Scope Level, Status
3. Create "Edit Role" dialog for changing UserRole and scope_level
4. Implement API composable with mock service (fallback when backend endpoints not available)
#### Task 2: Live "Gold Vehicle" AI Logs Tile (Launchpad)
1. Create "AI Logs Monitor" tile component for Launchpad
2. Implement polling mechanism (3-second intervals) using Vue's onMounted and setInterval
3. Fetch data from `/api/v1/vehicles/recent-activity` with mock fallback
4. Display real-time log entries with visual feedback
#### Task 3: Connect Existing API
1. Create API client for GET `/api/v1/admin/health-monitor`
2. Display metrics on System Health tile: total_assets, total_organizations, critical_alerts_24h
3. Ensure proper error handling and loading states
### Implementation Plan
#### Component Structure
```
frontend/admin/
├── pages/users.vue # User management page
├── components/UserDataTable.vue # Vuetify data table component
├── components/EditRoleDialog.vue # Role editing dialog
├── components/AiLogsTile.vue # AI Logs tile for Launchpad
├── composables/useUserManagement.ts # User management API composable
├── composables/useAiLogs.ts # AI logs polling composable
├── composables/useHealthMonitor.ts # Health monitor API composable
└── stores/users.ts # Pinia store for user data
```
#### API Integration Strategy
- **Mock Services:** Implement fallback mock data for development/testing
- **Real API:** Switch to real endpoints when backend is ready
- **Error Handling:** Graceful degradation with user notifications
- **Type Safety:** Full TypeScript interfaces for all API responses
#### RBAC Protection
- Route-level protection via middleware
- Component-level guards using `useRBAC` composable
- Visual indicators for unauthorized access attempts
### Progress Tracking
- [x] Ticket #113 set to "In Progress" via Gitea manager
- [x] User Management page created
- [x] Vuetify Data Table implemented
- [x] Edit Role dialog completed
- [x] API composables with mock services
- [x] AI Logs Tile component
- [x] Polling mechanism implemented
- [x] Health monitor API integration
- [x] System Health tile updated
- [x] Comprehensive testing
- [x] Ticket closure with technical summary
## Epic 10 - Ticket 2: Launchpad UI & Modular Tile System (#114)
**Date:** 2026-03-23
**Status:** In Progress
**Goal:** Upgrade the static Launchpad into a dynamic, drag-and-drop Grid system where users can rearrange their authorized tiles.
### Task Breakdown
#### Task 1: Drag-and-Drop Grid Implementation
1. Install vuedraggable (or equivalent Vue 3 compatible drag-and-drop grid system)
2. Refactor the Launchpad (Dashboard.vue) to use a draggable grid layout for the tiles
3. Ensure the grid is responsive (e.g., 1 column on mobile, multiple on desktop)
#### Task 2: Modular Tile Component Framework
1. Create a base TileWrapper.vue component that handles the drag handle (icon), title bar, and RBAC visibility checks
2. Wrap the existing AiLogsTile and SystemHealthTile inside this new wrapper
#### Task 3: Layout Persistence & "Reset to Default"
1. Update the Pinia store (usePreferencesStore) to handle layout state
2. Maintain a defaultLayout array (hardcoded standard order) and a userLayout array
3. Persist the userLayout to localStorage so the custom layout survives page reloads
4. Add a "Restore Default Layout" (Alapértelmezett elrendezés) UI button on the Launchpad that resets userLayout back to defaultLayout
### Implementation Plan
#### Component Structure Updates
```
frontend/admin/
├── components/TileWrapper.vue # Base tile wrapper with drag handle
├── components/AiLogsTile.vue # Updated to use wrapper
├── components/SystemHealthTile.vue # Updated to use wrapper
├── stores/preferences.ts # New store for layout preferences
└── pages/dashboard.vue # Updated with draggable grid
```
#### Technical Specifications
- **Drag & Drop Library:** `vuedraggable@next` (Vue 3 compatible)
- **Grid System:** CSS Grid with responsive breakpoints
- **State Persistence:** localStorage with fallback to default
- **RBAC Integration:** Tile visibility controlled via `useRBAC` composable
- **Default Layout:** Hardcoded array of tile IDs in order of appearance
### Progress Tracking
- [x] Ticket #114 set to "In Progress" via Gitea manager
- [x] TODO lista létrehozása development_log.md fájlban
- [x] vuedraggable csomag telepítése (v4.1.0 for Vue 3)
- [x] Dashboard.vue átalakítása draggable grid-re (Draggable component integration)
- [x] TileWrapper.vue alapkomponens létrehozása (with drag handle, RBAC badges, visibility toggle)
- [x] Meglévő tile-ok becsomagolása TileWrapper-be (TileCard wrapped in TileWrapper)
- [x] Pinia store frissítése layout kezeléshez (added defaultLayout and isLayoutModified computed properties)
- [x] Layout persistencia localStorage-ban (existing loadPreferences/savePreferences enhanced)
- [x] "Restore Default Layout" gomb implementálása (button with conditional display based on isLayoutModified)
- [x] Tesztelés és finomhangolás
- [x] Gitea Ticket #114 lezárása (Ticket closed with technical summary)
## Conclusion
The Epic 10 Admin Frontend Phase 1 & 2 implementation establishes a solid foundation for the Mission Control dashboard. The architecture supports the core requirements of geographical RBAC isolation, modular launchpad tiles, and role-based access control. The system is ready for integration with the backend FastAPI services and can be extended with additional tiles and features as specified in the epic specification.

View File

@@ -0,0 +1,69 @@
{
"navigation": {
"dashboard": "Dashboard",
"users": "Users",
"map": "Map",
"settings": "Settings",
"logout": "Logout",
"welcome": "Welcome",
"role_management": "Role Management",
"geographical_scopes": "Geographical Scopes"
},
"tiles": {
"ai_logs": "AI Logs",
"financial": "Financial",
"sales": "Sales",
"system_health": "System Health",
"service_map": "Service Map",
"moderation": "Moderation"
},
"general": {
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"confirm": "Confirm",
"role": "Role",
"scope": "Scope",
"status": "Status",
"actions": "Actions",
"search": "Search",
"filter": "Filter",
"refresh": "Refresh",
"loading": "Loading...",
"no_data": "No data available",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"settings": "Settings"
},
"dashboard": {
"title": "Admin Dashboard",
"subtitle": "Monitor and manage your service ecosystem",
"welcome_title": "Welcome to Mission Control",
"welcome_subtitle": "Real-time oversight for {scopeLevel} level administration",
"total_users": "Total Users",
"active_services": "Active Services",
"pending_requests": "Pending Requests",
"system_status": "System Status"
},
"users": {
"title": "User Management",
"add_user": "Add User",
"username": "Username",
"email": "Email",
"created_at": "Created At",
"last_login": "Last Login",
"active": "Active",
"inactive": "Inactive"
},
"login": {
"title": "Login to Admin",
"username": "Username",
"password": "Password",
"remember_me": "Remember me",
"forgot_password": "Forgot password?",
"sign_in": "Sign In"
}
}

View File

@@ -0,0 +1,69 @@
{
"navigation": {
"dashboard": "Irányítópult",
"users": "Felhasználók",
"map": "Térkép",
"settings": "Beállítások",
"logout": "Kijelentkezés",
"welcome": "Üdvözöljük",
"role_management": "Szerepkör Kezelés",
"geographical_scopes": "Földrajzi Hatáskörök"
},
"tiles": {
"ai_logs": "AI Naplók",
"financial": "Pénzügyi",
"sales": "Értékesítés",
"system_health": "Rendszerállapot",
"service_map": "Szolgáltatási Térkép",
"moderation": "Moderálás"
},
"general": {
"save": "Mentés",
"cancel": "Mégse",
"edit": "Szerkesztés",
"delete": "Törlés",
"confirm": "Megerősítés",
"role": "Szerepkör",
"scope": "Hatáskör",
"status": "Állapot",
"actions": "Műveletek",
"search": "Keresés",
"filter": "Szűrés",
"refresh": "Frissítés",
"loading": "Betöltés...",
"no_data": "Nincs elérhető adat",
"error": "Hiba",
"success": "Siker",
"warning": "Figyelmeztetés",
"info": "Információ",
"settings": "Beállítások"
},
"dashboard": {
"title": "Admin Irányítópult",
"subtitle": "Figyelje és kezelje szolgáltatási ökoszisztémáját",
"welcome_title": "Üdvözöljük a Mission Control-ban",
"welcome_subtitle": "Valós idejű felügyelet {scopeLevel} szintű adminisztrációhoz",
"total_users": "Összes felhasználó",
"active_services": "Aktív szolgáltatások",
"pending_requests": "Függőben lévő kérések",
"system_status": "Rendszerállapot"
},
"users": {
"title": "Felhasználókezelés",
"add_user": "Felhasználó hozzáadása",
"username": "Felhasználónév",
"email": "E-mail",
"created_at": "Létrehozva",
"last_login": "Utolsó bejelentkezés",
"active": "Aktív",
"inactive": "Inaktív"
},
"login": {
"title": "Bejelentkezés az Adminba",
"username": "Felhasználónév",
"password": "Jelszó",
"remember_me": "Emlékezz rám",
"forgot_password": "Elfelejtette a jelszavát?",
"sign_in": "Bejelentkezés"
}
}

View File

@@ -0,0 +1,83 @@
import { useAuthStore } from '~/stores/auth'
export default defineNuxtRouteMiddleware((to, from) => {
// Skip auth checks on server-side (SSR) - localStorage not available
if (process.server) {
return
}
const authStore = useAuthStore()
const nuxtApp = useNuxtApp()
// Public routes that don't require authentication
const publicRoutes = ['/login', '/forgot-password', '/reset-password']
// Check if route requires authentication
const requiresAuth = !publicRoutes.includes(to.path)
// If route requires auth and user is not authenticated, redirect to login
if (requiresAuth && !authStore.isAuthenticated) {
return navigateTo('/login')
}
// If user is authenticated and trying to access login page, redirect to dashboard
if (to.path === '/login' && authStore.isAuthenticated) {
return navigateTo('/dashboard')
}
// Check role-based access for protected routes
if (requiresAuth && authStore.isAuthenticated) {
const routeMeta = to.meta || {}
const requiredRole = routeMeta.requiredRole as string | undefined
const minRank = routeMeta.minRank as number | undefined
const requiredPermission = routeMeta.requiredPermission as string | undefined
// Check role requirement
if (requiredRole && authStore.getUserRole !== requiredRole) {
console.warn(`Access denied: Route requires role ${requiredRole}, user has ${authStore.getUserRole}`)
return navigateTo('/unauthorized')
}
// Check rank requirement
if (minRank !== undefined && !authStore.hasRank(minRank)) {
console.warn(`Access denied: Route requires rank ${minRank}, user has rank ${authStore.getUserRank}`)
return navigateTo('/unauthorized')
}
// Check permission requirement
if (requiredPermission && !authStore.hasPermission(requiredPermission)) {
console.warn(`Access denied: Route requires permission ${requiredPermission}`)
return navigateTo('/unauthorized')
}
// Check geographical scope for scoped routes
const requiredScopeId = routeMeta.requiredScopeId as number | undefined
const requiredRegionCode = routeMeta.requiredRegionCode as string | undefined
if (requiredScopeId || requiredRegionCode) {
if (!authStore.canAccessScope(requiredScopeId || 0, requiredRegionCode)) {
console.warn(`Access denied: User cannot access requested scope`)
return navigateTo('/unauthorized')
}
}
}
// Add auth headers to all API requests if authenticated
if (process.client && authStore.isAuthenticated && authStore.token) {
const { $api } = nuxtApp
if ($api && $api.defaults) {
$api.defaults.headers.common['Authorization'] = `Bearer ${authStore.token}`
// Add geographical scope headers for backend filtering
if (authStore.getScopeId) {
$api.defaults.headers.common['X-Scope-Id'] = authStore.getScopeId.toString()
}
if (authStore.getRegionCode) {
$api.defaults.headers.common['X-Region-Code'] = authStore.getRegionCode
}
if (authStore.getScopeLevel) {
$api.defaults.headers.common['X-Scope-Level'] = authStore.getScopeLevel
}
}
}
})

View File

@@ -0,0 +1,61 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: false },
modules: [
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'vuetify-nuxt-module',
'@nuxtjs/i18n'
],
i18n: {
locales: [
{ code: 'en', iso: 'en-US', file: 'en.json', name: 'English' },
{ code: 'hu', iso: 'hu-HU', file: 'hu.json', name: 'Magyar' }
],
defaultLocale: 'hu',
lazy: true,
langDir: 'locales',
strategy: 'no_prefix'
},
vuetify: {
moduleOptions: {
/* module specific options */
},
vuetifyOptions: {
/* vuetify options */
}
},
css: ['vuetify/lib/styles/main.sass', '@mdi/font/css/materialdesignicons.min.css'],
build: {
transpile: ['vuetify'],
},
vite: {
define: {
'process.env.DEBUG': false,
},
server: {
allowedHosts: ['admin.servicefinder.hu']
},
},
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8000',
appName: 'Service Finder Admin',
appVersion: '1.0.0'
}
},
// Nitro proxy configuration for Docker networking
routeRules: {
'/api/**': {
proxy: 'http://sf_api:8000/api/**',
// Add CORS headers for development
cors: true,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
}
}
}
})

View File

@@ -0,0 +1,40 @@
{
"name": "sf-admin-ui",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@nuxtjs/i18n": "^8.5.6",
"@nuxtjs/tailwindcss": "^6.8.0",
"@types/node": "^20.11.24",
"@unhead/vue": "^1.8.9",
"@vuetify/loader-shared": "^2.1.2",
"nuxt": "^3.11.0",
"sass-embedded": "^1.83.4",
"typescript": "^5.3.3",
"unhead": "^1.8.9",
"vuetify-nuxt-module": "^0.4.12"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@pinia/nuxt": "^0.5.1",
"axios": "^1.6.7",
"chart.js": "^4.4.1",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-chartjs": "^5.2.0",
"vue-router": "^4.2.5",
"vue3-leaflet": "^1.0.19",
"vuedraggable": "^4.1.0",
"vuetify": "^3.5.13"
}
}

View File

@@ -0,0 +1,760 @@
<template>
<v-app>
<!-- App Bar -->
<v-app-bar color="primary" prominent>
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title class="text-h5 font-weight-bold">
<v-icon icon="mdi-rocket-launch" class="mr-2"></v-icon>
{{ t('dashboard.title') }}
<v-chip class="ml-2" :color="roleColor" size="small">
{{ userRole }} {{ scopeLevel }}
</v-chip>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Language Switcher -->
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="mr-2">
<v-icon icon="mdi-translate"></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="locale = 'hu'">
<v-list-item-title :class="{ 'font-weight-bold': locale === 'hu' }">
🇭🇺 Magyar
</v-list-item-title>
</v-list-item>
<v-list-item @click="locale = 'en'">
<v-list-item-title :class="{ 'font-weight-bold': locale === 'en' }">
🇬🇧 English
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- User Menu -->
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<v-avatar size="40" color="secondary">
<v-icon icon="mdi-account"></v-icon>
</v-avatar>
</v-btn>
</template>
<v-list>
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ userEmail }}
</v-list-item-title>
<v-list-item-subtitle>
Rank: {{ userRank }} Scope ID: {{ scopeId }}
</v-list-item-subtitle>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="navigateTo('/profile')">
<v-list-item-title>
<v-icon icon="mdi-account-cog" class="mr-2"></v-icon>
{{ t('general.settings') }}
</v-list-item-title>
</v-list-item>
<v-list-item @click="logout">
<v-list-item-title class="text-error">
<v-icon icon="mdi-logout" class="mr-2"></v-icon>
{{ t('navigation.logout') }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<!-- Navigation Drawer -->
<v-navigation-drawer v-model="drawer" temporary>
<v-list>
<v-list-item prepend-icon="mdi-view-dashboard" :title="t('navigation.dashboard')" value="dashboard" @click="navigateTo('/dashboard')"></v-list-item>
<v-list-item prepend-icon="mdi-cog" :title="t('navigation.settings')" value="settings" @click="navigateTo('/settings')"></v-list-item>
<v-list-item prepend-icon="mdi-shield-account" :title="t('navigation.role_management')" value="roles" @click="navigateTo('/roles')"></v-list-item>
<v-list-item prepend-icon="mdi-map" :title="t('navigation.geographical_scopes')" value="scopes" @click="navigateTo('/scopes')"></v-list-item>
</v-list>
<template v-slot:append>
<v-list>
<v-list-item>
<v-list-item-title class="text-caption text-disabled">
Service Finder Admin v{{ appVersion }}
</v-list-item-title>
</v-list-item>
</v-list>
</template>
</v-navigation-drawer>
<!-- Main Content -->
<v-main>
<v-container fluid class="pa-6">
<!-- Welcome Header -->
<v-row class="mb-6">
<v-col cols="12">
<v-card color="primary" variant="tonal" class="pa-4">
<v-card-title class="text-h4 font-weight-bold">
<v-icon icon="mdi-rocket" class="mr-2"></v-icon>
{{ t('dashboard.welcome_title') }}
</v-card-title>
<v-card-subtitle class="text-h6">
{{ t('dashboard.welcome_subtitle', { scopeLevel }) }}
</v-card-subtitle>
<v-card-text>
<v-chip class="mr-2" color="success">
<v-icon icon="mdi-check-circle" class="mr-1"></v-icon>
Authenticated as {{ userRole }}
</v-chip>
<v-chip class="mr-2" color="info">
<v-icon icon="mdi-map-marker" class="mr-1"></v-icon>
Scope: {{ regionCode || 'Global' }}
</v-chip>
<v-chip color="warning">
<v-icon icon="mdi-shield-star" class="mr-1"></v-icon>
Rank: {{ userRank }}
</v-chip>
<!-- Layout Controls -->
<v-btn
v-if="tileStore.isLayoutModified"
class="ml-2"
color="warning"
size="small"
variant="outlined"
@click="restoreDefaultLayout"
:loading="isRestoringLayout"
>
<v-icon icon="mdi-restore" class="mr-1"></v-icon>
Restore Default Layout
</v-btn>
<v-tooltip v-else location="bottom">
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
class="ml-2"
color="success"
size="small"
variant="outlined"
>
<v-icon icon="mdi-check-all" class="mr-1"></v-icon>
Default Layout Active
</v-chip>
</template>
<span>Your dashboard layout matches the default configuration</span>
</v-tooltip>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Launchpad Section -->
<v-row class="mb-4">
<v-col cols="12">
<div class="d-flex align-center justify-space-between">
<v-card-title class="text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-view-grid" class="mr-2"></v-icon>
Launchpad
</v-card-title>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-cog">
Customize Tiles
</v-btn>
</div>
<v-card-subtitle class="pa-0">
Role-based dashboard with {{ filteredTiles.length }} accessible tiles
</v-card-subtitle>
</v-col>
</v-row>
<!-- Dynamic Tiles Grid with Drag & Drop -->
<Draggable
v-model="draggableTiles"
tag="v-row"
item-key="id"
class="drag-container"
@end="onDragEnd"
:component-data="{ class: 'drag-row' }"
:animation="200"
:ghost-class="'ghost-tile'"
:chosen-class="'chosen-tile'"
>
<template #item="{ element: tile }">
<v-col
cols="12"
sm="6"
md="4"
lg="3"
class="drag-col"
>
<TileWrapper :tile="tile" @click="handleTileClick">
<template #default>
<p class="text-body-2">{{ tile.description }}</p>
<!-- Requirements Badges -->
<div class="mt-2">
<v-chip
v-for="role in tile.requiredRole"
:key="role"
size="x-small"
class="mr-1 mb-1"
variant="outlined"
>
{{ role }}
</v-chip>
<v-chip
v-if="tile.minRank"
size="x-small"
class="mr-1 mb-1"
color="warning"
variant="outlined"
>
Rank {{ tile.minRank }}+
</v-chip>
</div>
<!-- Scope Level Indicator -->
<div v-if="tile.scopeLevel && tile.scopeLevel.length > 0" class="mt-2">
<v-icon icon="mdi-map-marker" size="small" class="mr-1"></v-icon>
<span class="text-caption">
{{ tile.scopeLevel.join(', ') }}
</span>
</div>
</template>
</TileWrapper>
</v-col>
</template>
<!-- Empty State -->
<template #footer v-if="draggableTiles.length === 0">
<v-col cols="12">
<v-card class="pa-8 text-center">
<v-icon icon="mdi-lock" size="64" class="mb-4 text-disabled"></v-icon>
<v-card-title class="text-h5">
No Tiles Available
</v-card-title>
<v-card-text>
Your current role ({{ userRole }}) doesn't have access to any dashboard tiles.
Contact your administrator for additional permissions.
</v-card-text>
</v-card>
</v-col>
</template>
</Draggable>
<!-- Quick Stats -->
<v-row class="mt-8">
<v-col cols="12">
<v-card-title class="text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-chart-line" class="mr-2"></v-icon>
System Health Dashboard
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
class="ml-2"
@click="healthMonitor.refreshAll"
:loading="healthMonitor.loading"
></v-btn>
</v-card-title>
<v-card-subtitle class="pa-0">
Real-time system metrics from health-monitor API
<v-chip
v-if="healthMonitor.metrics"
:color="healthMonitor.systemStatusColor"
size="small"
class="ml-2"
>
<v-icon :icon="healthMonitor.systemStatusIcon" size="small" class="mr-1"></v-icon>
{{ healthMonitor.metrics?.system_status?.toUpperCase() || 'LOADING' }}
</v-chip>
</v-card-subtitle>
</v-col>
<!-- Total Assets -->
<v-col cols="12" md="3">
<v-card
class="pa-4"
elevation="3"
rounded="xl"
:color="healthMonitor.loading ? 'grey-lighten-4' : 'surface'"
:loading="healthMonitor.loading && !healthMonitor.metrics"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon icon="mdi-car" class="mr-2" color="indigo-darken-2"></v-icon>
Total Vehicles
<v-spacer></v-spacer>
<v-progress-circular
v-if="healthMonitor.loading && !healthMonitor.metrics"
indeterminate
size="20"
width="2"
color="indigo"
></v-progress-circular>
</v-card-title>
<v-card-text class="text-h3 font-weight-bold text-indigo-darken-2">
{{ healthMonitor.metrics?.total_assets?.toLocaleString() || '--' }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon icon="mdi-database" size="small" class="mr-1"></v-icon>
Seeded vehicles in database
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center mt-2">
<v-icon icon="mdi-check-circle" color="success" size="small" class="mr-1"></v-icon>
<span class="text-caption text-disabled">Live from PostgreSQL</span>
</div>
</v-card>
</v-col>
<!-- Total Organizations -->
<v-col cols="12" md="3">
<v-card
class="pa-4"
elevation="3"
rounded="xl"
:color="healthMonitor.loading ? 'grey-lighten-4' : 'surface'"
:loading="healthMonitor.loading && !healthMonitor.metrics"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon icon="mdi-office-building" class="mr-2" color="teal-darken-2"></v-icon>
Organizations
<v-spacer></v-spacer>
<v-progress-circular
v-if="healthMonitor.loading && !healthMonitor.metrics"
indeterminate
size="20"
width="2"
color="teal"
></v-progress-circular>
</v-card-title>
<v-card-text class="text-h3 font-weight-bold text-teal-darken-2">
{{ healthMonitor.metrics?.total_organizations?.toLocaleString() || '--' }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon icon="mdi-domain" size="small" class="mr-1"></v-icon>
Registered business entities
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center mt-2">
<v-icon icon="mdi-check-circle" color="success" size="small" class="mr-1"></v-icon>
<span class="text-caption text-disabled">Real API data</span>
</div>
</v-card>
</v-col>
<!-- Active Users -->
<v-col cols="12" md="3">
<v-card
class="pa-4"
elevation="3"
rounded="xl"
:color="healthMonitor.loading ? 'grey-lighten-4' : 'surface'"
:loading="healthMonitor.loading && !healthMonitor.metrics"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon icon="mdi-account-group" class="mr-2" color="emerald-darken-2"></v-icon>
Active Users
<v-spacer></v-spacer>
<v-progress-circular
v-if="healthMonitor.loading && !healthMonitor.metrics"
indeterminate
size="20"
width="2"
color="emerald"
></v-progress-circular>
</v-card-title>
<v-card-text class="text-h3 font-weight-bold text-emerald-darken-2">
{{ healthMonitor.metrics?.active_users?.toLocaleString() || '--' }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon icon="mdi-account" size="small" class="mr-1"></v-icon>
Total registered users
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center mt-2">
<v-icon icon="mdi-check-circle" color="success" size="small" class="mr-1"></v-icon>
<span class="text-caption text-disabled">Including superadmin</span>
</div>
</v-card>
</v-col>
<!-- Critical Alerts -->
<v-col cols="12" md="3">
<v-card
class="pa-4"
elevation="3"
rounded="xl"
:color="healthMonitor.loading ? 'grey-lighten-4' : 'surface'"
:loading="healthMonitor.loading && !healthMonitor.metrics"
:border="healthMonitor.metrics?.critical_alerts_24h ? 'left' : false"
:color-border="healthMonitor.metrics?.critical_alerts_24h ? 'error' : 'success'"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon
:icon="healthMonitor.metrics?.critical_alerts_24h ? 'mdi-alert-octagon' : 'mdi-shield-check'"
class="mr-2"
:color="healthMonitor.metrics?.critical_alerts_24h ? 'error' : 'success'"
></v-icon>
System Health
<v-spacer></v-spacer>
<v-progress-circular
v-if="healthMonitor.loading && !healthMonitor.metrics"
indeterminate
size="20"
width="2"
:color="healthMonitor.metrics?.critical_alerts_24h ? 'error' : 'success'"
></v-progress-circular>
</v-card-title>
<v-card-text
class="text-h3 font-weight-bold"
:class="healthMonitor.metrics?.critical_alerts_24h ? 'text-error' : 'text-success'"
>
{{ healthMonitor.metrics?.critical_alerts_24h || 0 }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon
:icon="healthMonitor.metrics?.critical_alerts_24h ? 'mdi-alert' : 'mdi-check-circle'"
size="small"
class="mr-1"
:color="healthMonitor.metrics?.critical_alerts_24h ? 'error' : 'success'"
></v-icon>
<span :class="healthMonitor.metrics?.critical_alerts_24h ? 'text-error' : 'text-success'">
{{ healthMonitor.metrics?.critical_alerts_24h ? 'Critical alerts' : 'All systems operational' }}
</span>
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center mt-2">
<v-icon
:icon="healthMonitor.metrics?.critical_alerts_24h ? 'mdi-clock-alert' : 'mdi-clock-check'"
:color="healthMonitor.metrics?.critical_alerts_24h ? 'warning' : 'success'"
size="small"
class="mr-1"
></v-icon>
<span class="text-caption text-disabled">Last 24 hours</span>
</div>
</v-card>
</v-col>
</v-row>
<!-- Performance Metrics Row -->
<v-row class="mt-4">
<v-col cols="12" md="4">
<v-card
class="pa-4"
elevation="2"
rounded="lg"
color="surface-variant"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon icon="mdi-speedometer" class="mr-2" color="deep-purple"></v-icon>
System Uptime
</v-card-title>
<v-card-text class="text-h2 font-weight-bold text-deep-purple-darken-2">
{{ healthMonitor.formattedUptime }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon icon="mdi-heart-pulse" size="small" class="mr-1"></v-icon>
Service availability
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center justify-space-between">
<span class="text-caption">Response time:</span>
<span class="text-caption font-weight-medium" :class="getResponseTimeClass(healthMonitor.metrics?.response_time_ms)">
{{ healthMonitor.formattedResponseTime }}
<v-icon
:icon="getResponseTimeIcon(healthMonitor.metrics?.response_time_ms)"
size="small"
class="ml-1"
:color="getResponseTimeColor(healthMonitor.metrics?.response_time_ms)"
></v-icon>
</span>
</div>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card
class="pa-4"
elevation="2"
rounded="lg"
color="surface-variant"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon icon="mdi-database" class="mr-2" color="blue-grey"></v-icon>
Database Status
</v-card-title>
<v-card-text class="text-h2 font-weight-bold" :class="getDbConnectionClass(healthMonitor.metrics?.database_connections)">
{{ healthMonitor.metrics?.database_connections || '0' }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon icon="mdi-connection" size="small" class="mr-1"></v-icon>
Active connections
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center">
<v-icon
:icon="getDbStatusIcon(healthMonitor.metrics?.database_connections)"
:color="getDbStatusColor(healthMonitor.metrics?.database_connections)"
size="small"
class="mr-1"
></v-icon>
<span class="text-caption" :class="getDbStatusTextClass(healthMonitor.metrics?.database_connections)">
{{ getDbStatusText(healthMonitor.metrics?.database_connections) }}
</span>
</div>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card
class="pa-4"
elevation="2"
rounded="lg"
color="surface-variant"
>
<v-card-title class="text-h6 d-flex align-center">
<v-icon icon="mdi-update" class="mr-2" color="amber"></v-icon>
Data Freshness
</v-card-title>
<v-card-text class="text-h2 font-weight-bold text-amber-darken-2">
{{ healthMonitor.lastUpdated ? formatTime(healthMonitor.lastUpdated) : 'Never' }}
</v-card-text>
<v-card-subtitle class="text-caption">
<v-icon icon="mdi-clock-outline" size="small" class="mr-1"></v-icon>
Last API sync
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<div class="d-flex align-center justify-space-between">
<span class="text-caption">Auto-refresh:</span>
<v-chip size="x-small" color="info" variant="outlined">
<v-icon icon="mdi-autorenew" size="x-small" class="mr-1"></v-icon>
30s
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
<!-- Footer -->
<v-footer app color="surface" class="px-4">
<v-spacer></v-spacer>
<div class="text-caption text-disabled">
Geographical Scope: {{ regionCode || 'Global' }} •
Last sync: {{ new Date().toLocaleTimeString() }} •
<v-icon icon="mdi-circle-small" class="mx-1" color="success"></v-icon>
All systems operational
</div>
</v-footer>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '~/stores/auth'
import { useRBAC } from '~/composables/useRBAC'
import { useHealthMonitor } from '~/composables/useHealthMonitor'
import { useTileStore } from '~/stores/tiles'
import TileCard from '~/components/TileCard.vue'
import Draggable from 'vuedraggable'
import { useI18n } from 'vue-i18n'
// i18n
const { t, locale } = useI18n()
// State
const drawer = ref(false)
const appVersion = '1.0.0'
const tileStore = useTileStore()
const isRestoringLayout = ref(false)
const draggableTiles = ref<any[]>([])
// Stores and composables
const authStore = useAuthStore()
const rbac = useRBAC()
const healthMonitor = useHealthMonitor()
// Computed properties
const userEmail = computed(() => authStore.user?.email || '')
const userRole = computed(() => authStore.getUserRole || '')
const userRank = computed(() => authStore.getUserRank || 0)
const scopeLevel = computed(() => authStore.getScopeLevel || '')
const regionCode = computed(() => authStore.getRegionCode || '')
const scopeId = computed(() => authStore.getScopeId || 0)
const roleColor = computed(() => rbac.getRoleColor())
const filteredTiles = computed(() => tileStore.visibleTiles)
// Watch for changes to filteredTiles and update draggableTiles
watch(filteredTiles, (newTiles) => {
draggableTiles.value = [...newTiles]
}, { immediate: true })
// Methods
function logout() {
authStore.logout()
navigateTo('/login')
}
// Drag & Drop handling
function onDragEnd() {
const tileIds = draggableTiles.value.map(tile => tile.id)
tileStore.updateTilePositions(tileIds)
}
// Tile click handling
function handleTileClick(tile: any) {
const routes: Record<string, string> = {
'ai-logs': '/ai-logs',
'financial-dashboard': '/finance',
'salesperson-hub': '/sales',
'user-management': '/users',
'service-moderation-map': '/moderation-map',
'gamification-control': '/gamification',
'system-health': '/system'
}
const route = routes[tile.id]
if (route) {
navigateTo(route)
} else {
console.warn(`No route defined for tile: ${tile.id}`)
}
}
// Restore default layout
async function restoreDefaultLayout() {
isRestoringLayout.value = true
try {
tileStore.resetPreferences()
// Show success message
console.log('Layout restored to default')
} catch (error) {
console.error('Failed to restore layout:', error)
} finally {
isRestoringLayout.value = false
}
}
// Helper functions
const getDbConnectionClass = (connections: number | undefined) => {
if (!connections) return 'text-grey'
if (connections > 40) return 'text-error'
if (connections > 20) return 'text-warning'
return 'text-success'
}
const formatTime = (value: any) => {
if (!value) return 'N/A';
try {
const d = new Date(value);
// Check if it's a valid date
if (isNaN(d.getTime())) return String(value);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) {
return 'Invalid Time';
}
}
// Response time helper functions
const getResponseTimeClass = (responseTime: number | undefined) => {
if (!responseTime) return 'text-grey'
if (responseTime < 100) return 'text-success'
if (responseTime < 300) return 'text-warning'
return 'text-error'
}
const getResponseTimeIcon = (responseTime: number | undefined) => {
if (!responseTime) return 'mdi-help-circle'
if (responseTime < 100) return 'mdi-check'
if (responseTime < 300) return 'mdi-alert'
return 'mdi-alert-circle'
}
const getResponseTimeColor = (responseTime: number | undefined) => {
if (!responseTime) return 'grey'
if (responseTime < 100) return 'success'
if (responseTime < 300) return 'warning'
return 'error'
}
// Database status helper functions
const getDbStatusIcon = (connections: number | undefined) => {
if (!connections) return 'mdi-database-off'
if (connections > 40) return 'mdi-alert-circle'
if (connections > 20) return 'mdi-alert'
return 'mdi-check-circle'
}
const getDbStatusColor = (connections: number | undefined) => {
if (!connections) return 'grey'
if (connections > 40) return 'error'
if (connections > 20) return 'warning'
return 'success'
}
const getDbStatusTextClass = (connections: number | undefined) => {
if (!connections) return 'text-grey'
if (connections > 40) return 'text-error'
if (connections > 20) return 'text-warning'
return 'text-success'
}
const getDbStatusText = (connections: number | undefined) => {
if (!connections) return 'No data'
if (connections > 40) return 'High load'
if (connections > 20) return 'Moderate load'
return 'Normal load'
}
// Lifecycle
onMounted(() => {
console.log('Dashboard mounted for user:', userEmail.value)
// Initialize health monitor data
healthMonitor.initialize()
// Load tile preferences
tileStore.loadPreferences()
})
</script>
<style scoped>
.v-main {
background-color: #f5f5f5;
}
.v-card {
transition: transform 0.2s ease-in-out;
}
.v-card:hover {
transform: translateY(-4px);
}
/* Drag & Drop Styles */
.drag-container {
min-height: 200px;
}
.drag-row {
display: flex;
flex-wrap: wrap;
margin: -12px;
}
.drag-col {
padding: 12px;
}
.ghost-tile {
opacity: 0.5;
background-color: #e0e0e0;
border-radius: 8px;
}
.chosen-tile {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
z-index: 10;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<div>
<!-- Redirect to dashboard using Nuxt's navigateTo -->
<p>Redirecting to dashboard...</p>
</div>
</template>
<script setup lang="ts">
import { navigateTo } from '#app'
// Redirect to dashboard on mount
onMounted(() => {
navigateTo('/dashboard')
})
</script>

View File

@@ -0,0 +1,223 @@
<template>
<v-app>
<v-main class="d-flex align-center justify-center" style="min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<v-card width="400" class="pa-6" elevation="12">
<v-card-title class="text-h4 font-weight-bold text-center mb-4">
<v-icon icon="mdi-rocket-launch" class="mr-2" size="40"></v-icon>
Mission Control
</v-card-title>
<v-card-subtitle class="text-center mb-6">
Service Finder Admin Dashboard
</v-card-subtitle>
<v-form @submit.prevent="handleLogin" ref="loginForm">
<v-text-field
v-model="email"
label="Email"
type="email"
prepend-icon="mdi-email"
:rules="emailRules"
required
class="mb-4"
></v-text-field>
<v-text-field
v-model="password"
label="Password"
:type="showPassword ? 'text' : 'password'"
prepend-icon="mdi-lock"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="showPassword = !showPassword"
:rules="passwordRules"
required
class="mb-2"
></v-text-field>
<div class="d-flex justify-end mb-4">
<v-btn variant="text" size="small" @click="navigateTo('/forgot-password')">
Forgot Password?
</v-btn>
</div>
<v-btn
type="submit"
color="primary"
size="large"
block
:loading="isLoading"
class="mb-4"
>
<v-icon icon="mdi-login" class="mr-2"></v-icon>
Sign In
</v-btn>
<!-- Real API Login Only - No Dev Bypass -->
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mb-4"
>
{{ error }}
</v-alert>
<v-divider class="my-4"></v-divider>
<div class="text-center">
<p class="text-caption text-disabled">
Demo Credentials
</p>
<v-chip-group class="justify-center">
<v-chip size="small" variant="outlined" @click="setDemoCredentials('superadmin')">
Superadmin
</v-chip>
<v-chip size="small" variant="outlined" @click="setDemoCredentials('admin')">
Admin
</v-chip>
<v-chip size="small" variant="outlined" @click="setDemoCredentials('tester')">
Tester
</v-chip>
</v-chip-group>
</div>
</v-form>
</v-card>
<!-- Footer -->
<v-footer absolute class="px-4" color="transparent">
<v-spacer></v-spacer>
<div class="text-caption text-white">
Service Finder Admin v1.0.0 Epic 10 - Mission Control
</div>
</v-footer>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '~/stores/auth'
import { navigateTo } from '#app'
// State
const email = ref('')
const password = ref('')
const showPassword = ref(false)
const isLoading = ref(false)
const error = ref('')
const loginForm = ref()
// Validation rules
const emailRules = [
(v: string) => !!v || 'Email is required',
(v: string) => /.+@.+\..+/.test(v) || 'Email must be valid'
]
const passwordRules = [
(v: string) => !!v || 'Password is required',
(v: string) => v.length >= 6 || 'Password must be at least 6 characters'
]
// Store
const authStore = useAuthStore()
// Demo credentials - Using real credentials from the task
const demoCredentials = {
superadmin: {
email: 'superadmin@profibot.hu',
password: 'Superadmin123!',
role: 'superadmin',
rank: 100,
scope_level: 'global'
},
admin: {
email: 'admin@profibot.hu',
password: 'Admin123!',
role: 'admin',
rank: 50,
scope_level: 'global'
},
tester: {
email: 'tester_pro@profibot.hu',
password: 'Tester123!',
role: 'tester',
rank: 30,
scope_level: 'global'
}
}
// Set demo credentials
function setDemoCredentials(role: keyof typeof demoCredentials) {
const creds = demoCredentials[role]
email.value = creds.email
password.value = creds.password
// Show role info
error.value = `Demo ${role} credentials loaded. Role: ${creds.role}, Rank: ${creds.rank}, Scope: ${creds.scope_level}`
}
// Handle login - REAL API AUTHENTICATION ONLY
async function handleLogin() {
// Validate form
const { valid } = await loginForm.value.validate()
if (!valid) return
isLoading.value = true
error.value = ''
try {
// Debug: Log the input values
console.log('Attempting login with:', email.value, password.value)
console.log('Email type:', typeof email.value, 'Password type:', typeof password.value)
// For demo purposes, simulate login with demo credentials
const role = Object.keys(demoCredentials).find(key =>
demoCredentials[key as keyof typeof demoCredentials].email === email.value
)
if (role) {
const creds = demoCredentials[role as keyof typeof demoCredentials]
// In development mode, use the auth store's login function which has the mock bypass
// This will trigger the dev mode bypass in auth.ts for admin@servicefinder.com
const success = await authStore.login(email.value, password.value)
if (!success) {
error.value = 'Invalid credentials. Please try again.'
} else {
// Redirect to dashboard on successful login
await navigateTo('/dashboard')
}
} else {
// Simulate API call for real credentials
const success = await authStore.login(email.value, password.value)
if (!success) {
error.value = 'Invalid credentials. Please try again.'
} else {
// Redirect to dashboard on successful login
await navigateTo('/dashboard')
}
}
} catch (err) {
console.error('Login error:', err)
error.value = err instanceof Error ? err.message : 'Login failed'
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.v-card {
border-radius: 16px;
}
.v-chip {
cursor: pointer;
}
.v-chip:hover {
transform: translateY(-2px);
transition: transform 0.2s ease;
}
</style>

View File

@@ -0,0 +1,311 @@
<template>
<div class="moderation-map-page">
<div class="page-header">
<h1>Geographical Service Map</h1>
<p class="subtitle">Visualize and moderate services within your geographical scope</p>
</div>
<div class="controls">
<div class="scope-selector">
<label for="scope">Change Scope:</label>
<select id="scope" v-model="selectedScopeId" @change="onScopeChange">
<option v-for="scope in availableScopes" :key="scope.id" :value="scope.id">
{{ scope.label }}
</option>
</select>
<button @click="refreshData" class="btn-refresh">Refresh Data</button>
</div>
<div class="stats">
<div class="stat-card">
<span class="stat-label">Total Services</span>
<span class="stat-value">{{ services.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Pending</span>
<span class="stat-value pending">{{ pendingServices.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Approved</span>
<span class="stat-value approved">{{ approvedServices.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">In Scope</span>
<span class="stat-value">{{ servicesInScope.length }}</span>
</div>
</div>
</div>
<div class="map-container">
<ServiceMap
:services="servicesInScope"
:scope-label="scopeLabel"
/>
</div>
<div class="service-list">
<h2>Services in Scope</h2>
<table class="service-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Address</th>
<th>Distance</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="service in servicesInScope" :key="service.id">
<td>{{ service.name }}</td>
<td>
<span :class="service.status" class="status-badge">
{{ service.status }}
</span>
</td>
<td>{{ service.address }}</td>
<td>{{ service.distance }} km</td>
<td>
<button
@click="approveService(service.id)"
:disabled="service.status === 'approved'"
class="btn-action"
>
{{ service.status === 'approved' ? 'Approved' : 'Approve' }}
</button>
<button @click="zoomToService(service)" class="btn-action secondary">
View on Map
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ServiceMap from '~/components/map/ServiceMap.vue'
import { useServiceMap, type Service } from '~/composables/useServiceMap'
const {
services,
pendingServices,
approvedServices,
scopeLabel,
currentScope,
servicesInScope,
approveService: approveServiceComposable,
changeScope,
availableScopes
} = useServiceMap()
const selectedScopeId = ref(currentScope.value.id)
const onScopeChange = () => {
const scope = availableScopes.find(s => s.id === selectedScopeId.value)
if (scope) {
changeScope(scope)
}
}
const refreshData = () => {
// In a real app, this would fetch fresh data from API
console.log('Refreshing data...')
}
const zoomToService = (service: Service) => {
// This would zoom the map to the service location
console.log('Zooming to service:', service)
// In a real implementation, we would emit an event to the ServiceMap component
}
const approveService = (serviceId: number) => {
approveServiceComposable(serviceId)
}
</script>
<style scoped>
.moderation-map-page {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
font-size: 2.5rem;
color: #333;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 1.1rem;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 20px;
}
.scope-selector {
display: flex;
align-items: center;
gap: 10px;
}
.scope-selector label {
font-weight: bold;
}
.scope-selector select {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #ccc;
background: white;
font-size: 1rem;
}
.btn-refresh {
background-color: #4a90e2;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-refresh:hover {
background-color: #3a7bc8;
}
.stats {
display: flex;
gap: 15px;
}
.stat-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px 20px;
min-width: 120px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.stat-label {
display: block;
font-size: 0.9rem;
color: #666;
margin-bottom: 5px;
}
.stat-value {
display: block;
font-size: 1.8rem;
font-weight: bold;
color: #333;
}
.stat-value.pending {
color: #3b82f6;
}
.stat-value.approved {
color: #28a745;
}
.map-container {
margin-bottom: 30px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.service-list {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.service-list h2 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
}
.service-table {
width: 100%;
border-collapse: collapse;
}
.service-table thead {
background-color: #f8f9fa;
}
.service-table th {
padding: 12px 15px;
text-align: left;
font-weight: bold;
color: #495057;
border-bottom: 2px solid #dee2e6;
}
.service-table td {
padding: 12px 15px;
border-bottom: 1px solid #dee2e6;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
text-transform: uppercase;
}
.status-badge.pending {
background-color: #dbeafe;
color: #1d4ed8;
}
.status-badge.approved {
background-color: #d4edda;
color: #155724;
}
.btn-action {
background-color: #28a745;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
margin-right: 8px;
}
.btn-action:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.btn-action.secondary {
background-color: #6c757d;
}
.btn-action:hover:not(:disabled) {
opacity: 0.9;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#28a745" stroke="#fff" stroke-width="2"/>
<path d="M12 16l4 4 8-8" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#ffc107" stroke="#fff" stroke-width="2"/>
<path d="M12 16h8" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1,239 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { jwtDecode } from 'jwt-decode'
export interface JwtPayload {
sub: string
role: string
rank: number
scope_level: string
region_code?: string
scope_id?: number
exp: number
iat: number
}
export interface User {
id: string
email: string
role: string
rank: number
scope_level: string
region_code?: string
scope_id?: number
permissions: string[]
}
export const useAuthStore = defineStore('auth', () => {
// State
const token = ref<string | null>(null)
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!token.value && !isTokenExpired())
const isLoading = ref(false)
const error = ref<string | null>(null)
// Initialize token from localStorage only on client side
if (typeof window !== 'undefined') {
token.value = localStorage.getItem('admin_token')
}
// Getters
const getUserRole = computed(() => user.value?.role || '')
const getUserRank = computed(() => user.value?.rank || 0)
const getScopeLevel = computed(() => user.value?.scope_level || '')
const getRegionCode = computed(() => user.value?.region_code || '')
const getScopeId = computed(() => user.value?.scope_id || 0)
const getPermissions = computed(() => user.value?.permissions || [])
// Check if token is expired
function isTokenExpired(): boolean {
if (!token.value) return true
try {
const decoded = jwtDecode<JwtPayload>(token.value)
return Date.now() >= decoded.exp * 1000
} catch {
return true
}
}
// Parse token and set user
function parseToken(): void {
if (!token.value) {
user.value = null
return
}
try {
const decoded = jwtDecode<JwtPayload>(token.value)
// Map JWT claims to user object
user.value = {
id: decoded.sub,
email: decoded.sub, // Assuming sub is email
role: decoded.role,
rank: decoded.rank,
scope_level: decoded.scope_level,
region_code: decoded.region_code,
scope_id: decoded.scope_id,
permissions: generatePermissions(decoded.role, decoded.rank)
}
error.value = null
} catch (err) {
console.error('Failed to parse token:', err)
error.value = 'Invalid token format'
user.value = null
// Clear invalid token from storage
token.value = null
if (typeof window !== 'undefined') {
localStorage.removeItem('admin_token')
}
}
}
// Generate permissions based on role and rank
function generatePermissions(role: string, rank: number): string[] {
const permissions: string[] = []
// Base permissions based on role
switch (role) {
case 'superadmin':
permissions.push('*')
break
case 'admin':
permissions.push('view:dashboard', 'manage:users', 'manage:services', 'view:finance')
if (rank >= 5) permissions.push('manage:settings')
break
case 'moderator':
permissions.push('view:dashboard', 'moderate:services', 'view:users')
break
case 'salesperson':
permissions.push('view:dashboard', 'view:sales', 'manage:leads')
break
}
// Add geographical scope permissions
permissions.push(`scope:${role}`)
return permissions
}
// Check if user has permission
function hasPermission(permission: string): boolean {
if (!user.value) return false
if (user.value.permissions.includes('*')) return true
return user.value.permissions.includes(permission)
}
// Check if user has required role rank
function hasRank(minRank: number): boolean {
return user.value?.rank >= minRank
}
// Check if user can access scope
function canAccessScope(requestedScopeId: number, requestedRegionCode?: string): boolean {
if (!user.value) return false
// Superadmin can access everything
if (user.value.role === 'superadmin') return true
// Check scope_id match
if (user.value.scope_id && user.value.scope_id === requestedScopeId) return true
// Check region_code match
if (user.value.region_code && requestedRegionCode) {
return user.value.region_code === requestedRegionCode
}
return false
}
// Login action - REAL API AUTHENTICATION ONLY
async function login(email: string, password: string): Promise<boolean> {
isLoading.value = true
error.value = null
try {
// Debug: Log what we're sending
console.log('Auth store: Attempting login for', email)
console.log('Auth store: Password length', password.length)
// Prepare URL-encoded form data for OAuth2 password grant (as per FastAPI auth endpoint)
// FastAPI's OAuth2PasswordRequestForm expects application/x-www-form-urlencoded
// Use explicit string encoding to guarantee FastAPI accepts it (Nuxt's $fetch messes up URLSearchParams)
const bodyString = `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`;
console.log('Auth store: Body string created', bodyString)
// Call real backend login endpoint using $fetch (Nuxt's fetch)
// $fetch automatically throws on non-2xx responses, so we just need to catch
const data = await $fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: bodyString
})
console.log('Auth login API response:', data)
// Extract token
const accessToken = data.access_token
if (!accessToken) {
throw new Error('No access token in response')
}
// Store token safely (SSR-safe)
if (typeof window !== 'undefined') {
localStorage.setItem('admin_token', accessToken)
}
token.value = accessToken
parseToken()
return true
} catch (err) {
console.error('Auth store: Login catch block error:', err)
error.value = err instanceof Error ? err.message : 'Login failed'
return false
} finally {
isLoading.value = false
}
}
// Logout action
function logout(): void {
token.value = null
user.value = null
if (typeof window !== 'undefined') {
localStorage.removeItem('admin_token')
}
}
// Initialize store
if (token.value) {
parseToken()
}
return {
// State
token,
user,
isAuthenticated,
isLoading,
error,
// Getters
getUserRole,
getUserRank,
getScopeLevel,
getRegionCode,
getScopeId,
getPermissions,
// Actions
login,
logout,
hasPermission,
hasRank,
canAccessScope,
parseToken
}
})

View File

@@ -0,0 +1,204 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'
import { useRBAC, type TilePermission } from '~/composables/useRBAC'
export interface UserTilePreference {
tileId: string
visible: boolean
position: number
size: 'small' | 'medium' | 'large'
}
export const useTileStore = defineStore('tiles', () => {
const authStore = useAuthStore()
const rbac = useRBAC()
// State
const userPreferences = ref<Record<string, UserTilePreference>>({})
const isLoading = ref(false)
// Initialize from localStorage
function loadPreferences() {
if (typeof window === 'undefined') return
const userId = authStore.user?.id
if (!userId) return
const stored = localStorage.getItem(`tile_preferences_${userId}`)
if (stored) {
try {
userPreferences.value = JSON.parse(stored)
} catch (err) {
console.error('Failed to parse tile preferences:', err)
userPreferences.value = {}
}
}
}
// Save to localStorage
function savePreferences() {
if (typeof window === 'undefined') return
const userId = authStore.user?.id
if (!userId) return
localStorage.setItem(`tile_preferences_${userId}`, JSON.stringify(userPreferences.value))
}
// Get default layout (sorted by tile ID for consistency)
const defaultLayout = computed(() => {
const filtered = rbac.getFilteredTiles()
return filtered.map((tile, index) => ({
tileId: tile.id,
visible: true,
position: index,
size: 'medium' as const
}))
})
// Check if layout has been modified from default
const isLayoutModified = computed(() => {
const currentPrefs = Object.values(userPreferences.value)
const defaultPrefs = defaultLayout.value
if (currentPrefs.length !== defaultPrefs.length) return true
// Check if any preference differs from default
for (const defaultPref of defaultPrefs) {
const currentPref = userPreferences.value[defaultPref.tileId]
if (!currentPref) return true
if (currentPref.visible !== defaultPref.visible ||
currentPref.position !== defaultPref.position ||
currentPref.size !== defaultPref.size) {
return true
}
}
return false
})
// Get user's accessible tiles with preferences
const accessibleTiles = computed(() => {
const filtered = rbac.getFilteredTiles()
return filtered.map(tile => {
const pref = userPreferences.value[tile.id] || {
tileId: tile.id,
visible: true,
position: 0,
size: 'medium' as const
}
return {
...tile,
preference: pref
}
}).sort((a, b) => a.preference.position - b.preference.position)
})
// Get visible tiles only
const visibleTiles = computed(() => {
return accessibleTiles.value.filter(tile => tile.preference.visible)
})
// Update tile preference
function updateTilePreference(tileId: string, updates: Partial<UserTilePreference>) {
const current = userPreferences.value[tileId] || {
tileId,
visible: true,
position: Object.keys(userPreferences.value).length,
size: 'medium'
}
userPreferences.value[tileId] = {
...current,
...updates
}
savePreferences()
}
// Toggle tile visibility
function toggleTileVisibility(tileId: string) {
const current = userPreferences.value[tileId]
updateTilePreference(tileId, {
visible: !(current?.visible ?? true)
})
}
// Update tile positions (for drag and drop)
function updateTilePositions(tileIds: string[]) {
tileIds.forEach((tileId, index) => {
updateTilePreference(tileId, { position: index })
})
}
// Reset to default preferences
function resetPreferences() {
const userId = authStore.user?.id
if (userId) {
localStorage.removeItem(`tile_preferences_${userId}`)
}
userPreferences.value = {}
// Reinitialize with default positions
const tiles = rbac.getFilteredTiles()
tiles.forEach((tile, index) => {
userPreferences.value[tile.id] = {
tileId: tile.id,
visible: true,
position: index,
size: 'medium'
}
})
savePreferences()
}
// Get tile size class for grid
function getTileSizeClass(size: 'small' | 'medium' | 'large'): string {
switch (size) {
case 'small': return 'cols-12 sm-6 md-4 lg-3'
case 'medium': return 'cols-12 sm-6 md-6 lg-4'
case 'large': return 'cols-12 md-12 lg-8'
default: return 'cols-12 sm-6 md-4 lg-3'
}
}
// Initialize when auth changes
authStore.$subscribe(() => {
if (authStore.isAuthenticated) {
loadPreferences()
} else {
userPreferences.value = {}
}
})
// Initial load
if (authStore.isAuthenticated) {
loadPreferences()
}
return {
// State
userPreferences,
isLoading,
// Getters
accessibleTiles,
visibleTiles,
defaultLayout,
isLayoutModified,
// Actions
updateTilePreference,
toggleTileVisibility,
updateTilePositions,
resetPreferences,
getTileSizeClass,
loadPreferences,
savePreferences
}
})

View File

@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
// Use internal Docker network hostname for frontend
const FRONTEND_URL = 'http://sf_public_frontend:5173';
// Test user credentials (should be a valid test user in the dev database)
const TEST_EMAIL = 'superadmin@profibot.hu';
const TEST_PASSWORD = 'anypassword';
test.describe('Frontend UI E2E Flow', () => {
test('should login, select profile mode, and load dashboard', async ({ page }) => {
// Step 1: Open login page
await page.goto(`${FRONTEND_URL}/login`);
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /login/i })).toBeVisible();
// Step 2: Fill credentials and submit
await page.getByLabel(/email/i).fill(TEST_EMAIL);
await page.getByLabel(/password/i).fill(TEST_PASSWORD);
await page.getByRole('button', { name: /sign in|login/i }).click();
// Step 3: Wait for redirect to profile selection (since no UI mode selected)
await expect(page).toHaveURL(/\/profile-select/);
await expect(page.getByRole('heading', { name: /welcome to service finder/i })).toBeVisible();
// Step 4: Select Private Garage mode
await page.getByText(/private garage/i).click();
await expect(page.locator('.selected').filter({ hasText: /private garage/i })).toBeVisible();
// Step 5: Click Continue to Dashboard
await page.getByRole('button', { name: /continue to dashboard/i }).click();
// Step 6: Verify dashboard loads
await expect(page).toHaveURL(/\//);
await expect(page.getByRole('heading').filter({ hasText: /dashboard/i }).first()).toBeVisible();
// Step 7: Verify gamification trophies are present
await expect(page.getByText(/trophies|achievements/i).first()).toBeVisible();
// Step 8: Verify "Add Expense" link is present and clickable
const addExpenseLink = page.getByRole('link', { name: /add expense/i });
await expect(addExpenseLink).toBeVisible();
await addExpenseLink.click();
// Should navigate to add expense page
await expect(page.getByRole('heading', { name: /add expense/i })).toBeVisible();
});
test('should handle corporate fleet selection', async ({ page }) => {
await page.goto(`${FRONTEND_URL}/login`);
await page.getByLabel(/email/i).fill(TEST_EMAIL);
await page.getByLabel(/password/i).fill(TEST_PASSWORD);
await page.getByRole('button', { name: /sign in|login/i }).click();
await expect(page).toHaveURL(/\/profile-select/);
await page.getByText(/corporate fleet/i).click();
await page.getByRole('button', { name: /continue to dashboard/i }).click();
await expect(page).toHaveURL(/\//);
// Fleet dashboard may have different elements, but at least dashboard title
await expect(page.getByRole('heading').filter({ hasText: /dashboard/i }).first()).toBeVisible();
});
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,197 @@
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import DailyQuizModal from '@/components/DailyQuizModal.vue'
import QuickActionsFAB from '@/components/actions/QuickActionsFAB.vue'
import { ref } from 'vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const themeStore = useThemeStore()
const handleLogout = () => {
authStore.logout()
}
const toggleTheme = () => {
themeStore.toggleTheme()
}
// Legal modal state
const showLegalModal = ref(false)
const legalModalTitle = ref('')
const legalModalContent = ref('')
const openLegalModal = (type) => {
if (type === 'aszf') {
legalModalTitle.value = 'Általános Szerződési Feltételek (ÁSZF)'
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... A Service Finder szolgáltatás használatával Ön elfogadja az Általános Szerződési Feltételeket, amelyek meghatározzák a szolgáltatás használatának feltételeit, a felelősség korlátozását és a felhasználói jogokat.'
} else if (type === 'adatkezeles') {
legalModalTitle.value = 'Adatkezelési Tájékoztató (GDPR)'
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... A Service Finder tiszteletben tartja az Ön adatvédelmét. E tájékoztató részletezi, hogyan gyűjtjük, tároljuk és kezeljük személyes adatait az Európai Unió Általános Adatvédelmi Rendelete (GDPR) és a vonatkozó magyar jogszabályok értelmében.'
} else if (type === 'cookies') {
legalModalTitle.value = 'Sütik (Cookies) Használata'
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... Weboldalunk sütiket (cookies) használ a felhasználói élmény javítása, a funkciók működésének biztosítása és a forgalom elemzése érdekében. A sütik használatával kapcsolatos részletes információkat itt találja.'
}
showLegalModal.value = true
}
const closeLegalModal = () => {
showLegalModal.value = false
}
</script>
<template>
<div :class="['min-h-screen flex flex-col font-sans transition-all duration-500', themeStore.themeClasses.background, themeStore.themeClasses.text]">
<!-- Premium Navigation -->
<nav class="bg-gradient-to-r from-slate-800 to-slate-900 text-white p-4 shadow-xl flex justify-between items-center z-50 border-b border-slate-700/50 backdrop-blur-md">
<div class="flex items-center gap-3 cursor-pointer group" @click="router.push('/')">
<div class="p-2 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 group-hover:from-blue-600 group-hover:to-cyan-600 transition-all duration-200">
<span class="text-2xl">🚗</span>
</div>
<div class="flex flex-col">
<span class="font-bold text-xl tracking-tight">Service Finder</span>
<span class="text-xs text-slate-300">Premium Vehicle Management</span>
</div>
</div>
<div class="space-x-6 hidden md:flex items-center">
<template v-if="authStore.isLoggedIn">
<router-link to="/" class="nav-link text-slate-200 hover:text-white font-medium transition-colors duration-200 hover:scale-105">Dashboard</router-link>
<router-link to="/expenses" class="nav-link text-slate-200 hover:text-white font-medium transition-colors duration-200 hover:scale-105">Költségek</router-link>
<router-link v-if="authStore.isAdmin" to="/admin" class="text-amber-300 font-bold hover:text-amber-200 transition-colors duration-200 hover:scale-105"> Admin</router-link>
<!-- User Role Badge -->
<div v-if="authStore.isTester" class="px-3 py-1.5 bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg text-xs font-bold text-white border border-purple-500/50 shadow-md animate-pulse">
🧪 {{ authStore.displayName }}
</div>
<div v-else-if="authStore.isAdmin" class="px-3 py-1.5 bg-gradient-to-r from-amber-600 to-orange-600 rounded-lg text-xs font-bold text-white border border-amber-500/50 shadow-md">
{{ authStore.displayName }}
</div>
<button @click="toggleTheme" :class="['px-4 py-2.5 rounded-xl text-sm transition-all duration-200 active:scale-95 border shadow-md hover:shadow-lg', themeStore.isLuxury ? 'bg-gradient-to-r from-amber-700 to-amber-800 border-amber-600/50 text-white hover:from-amber-800 hover:to-amber-900' : 'bg-gradient-to-r from-orange-700 to-orange-800 border-orange-600/50 text-white hover:from-orange-800 hover:to-orange-900']">
{{ themeStore.isLuxury ? '🏛️ Luxury' : '🔧 Workshop' }}
</button>
<button @click="handleLogout" class="bg-gradient-to-r from-slate-700 to-slate-800 px-5 py-2.5 rounded-xl text-sm hover:from-slate-800 hover:to-slate-900 transition-all duration-200 active:scale-95 border border-slate-600/50 shadow-md hover:shadow-lg">
Kijelentkezés
</button>
</template>
<template v-else>
<router-link to="/login" class="text-slate-200 hover:text-white font-medium transition-colors duration-200">Bejelentkezés</router-link>
<router-link to="/register" class="bg-gradient-to-r from-blue-600 to-cyan-600 px-5 py-2.5 rounded-xl text-sm hover:from-blue-700 hover:to-cyan-700 transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
Regisztráció
</router-link>
</template>
</div>
<!-- Mobile menu button -->
<button class="md:hidden p-2 rounded-lg bg-slate-700/50 hover:bg-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</nav>
<!-- Main Content -->
<main class="flex-1 p-4 md:p-6 max-w-7xl mx-auto w-full">
<router-view />
</main>
<!-- Daily Quiz Modal -->
<DailyQuizModal v-if="authStore.isLoggedIn && route.path !== '/login' && route.path !== '/register' && route.path !== '/debug'" />
<!-- Quick Actions FAB -->
<QuickActionsFAB v-if="authStore.isLoggedIn && route.path !== '/login' && route.path !== '/register' && route.path !== '/debug'" />
<!-- Legal Modal -->
<div v-if="showLegalModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-all duration-300">
<div class="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl shadow-2xl border border-slate-700/50 w-full max-w-2xl mx-4 overflow-hidden transform transition-all duration-300 scale-100">
<div class="p-6 border-b border-slate-700/50">
<div class="flex justify-between items-center">
<h3 class="text-xl font-bold text-white">{{ legalModalTitle }}</h3>
<button @click="closeLegalModal" class="text-slate-400 hover:text-white transition-colors duration-200 p-2 rounded-lg hover:bg-slate-700/50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="p-6 max-h-[60vh] overflow-y-auto">
<div class="prose prose-invert max-w-none">
<p class="text-slate-300 mb-4">{{ legalModalContent }}</p>
<div class="bg-slate-800/50 rounded-xl p-4 border border-slate-700/50 mt-6">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 flex items-center justify-center">
<span class="text-white font-bold"></span>
</div>
<div>
<p class="text-sm text-slate-400">Ez egy helykitöltő szöveg. A végleges jogi dokumentáció a termék bevezetésével együtt kerül feltöltésre.</p>
</div>
</div>
</div>
</div>
</div>
<div class="p-6 border-t border-slate-700/50 bg-slate-900/50">
<div class="flex justify-end">
<button @click="closeLegalModal" class="px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white font-medium rounded-xl transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
Bezárás
</button>
</div>
</div>
</div>
</div>
<!-- Premium Footer -->
<footer class="mt-auto bg-gradient-to-r from-slate-800 to-slate-900 text-slate-300 p-6 border-t border-slate-700/50">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center">
<div class="mb-4 md:mb-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-2xl">🚗</span>
<span class="font-bold text-white">Service Finder</span>
</div>
<p class="text-sm text-slate-400">Premium vehicle management for individuals and businesses</p>
</div>
<div class="flex gap-6">
<button @click="openLegalModal('aszf')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">ÁSZF</button>
<button @click="openLegalModal('adatkezeles')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">Adatkezelési Tájékoztató</button>
<button @click="openLegalModal('cookies')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">Sütik</button>
<a href="#" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm">Kapcsolat</a>
</div>
</div>
<div class="max-w-7xl mx-auto mt-6 pt-6 border-t border-slate-700/50 text-center text-xs text-slate-500">
© 2026 Service Finder. Minden jog fenntartva. Gépjármű-rajongók számára készült precízióval.
</div>
</footer>
</div>
</template>
<style scoped>
.nav-link {
position: relative;
padding: 0.5rem 0;
}
.nav-link::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(to right, #3b82f6, #06b6d4);
transition: width 0.3s ease;
}
.nav-link:hover::after {
width: 100%;
}
/* Smooth transitions */
* {
transition-property: color, background-color, border-color, transform, box-shadow;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,231 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuizStore } from '@/stores/quizStore'
const quizStore = useQuizStore()
const showModal = ref(false)
const currentQuestionIndex = ref(0)
const selectedOption = ref(null)
const showResult = ref(false)
const isCorrect = ref(false)
const resultExplanation = ref('')
const isLoading = ref(false)
const currentQuestion = computed(() => quizStore.questions[currentQuestionIndex.value])
const totalQuestions = computed(() => quizStore.totalQuestions)
const progress = computed(() => ((currentQuestionIndex.value + 1) / totalQuestions.value) * 100)
// Auto-show modal after 3-5 seconds if canPlayToday
onMounted(() => {
if (quizStore.canPlayToday) {
setTimeout(() => {
openModal()
}, 3500) // 3.5 seconds
}
})
async function openModal() {
if (!quizStore.canPlayToday) {
alert('Már játszottál ma! Holnap próbáld újra.')
return
}
isLoading.value = true
try {
// Fetch daily quiz questions from API
await quizStore.fetchDailyQuiz()
resetQuiz()
showModal.value = true
} catch (error) {
console.error('Failed to load daily quiz:', error)
alert('Hiba történt a kvíz betöltése közben. Próbáld újra később.')
} finally {
isLoading.value = false
}
}
function closeModal() {
showModal.value = false
}
function resetQuiz() {
currentQuestionIndex.value = 0
selectedOption.value = null
showResult.value = false
isCorrect.value = false
resultExplanation.value = ''
}
async function selectOption(optionIndex) {
if (showResult.value) return
selectedOption.value = optionIndex
try {
const result = await quizStore.answerQuestion(currentQuestion.value.id, optionIndex)
isCorrect.value = result.is_correct
resultExplanation.value = result.explanation
showResult.value = true
} catch (error) {
console.error('Failed to submit answer:', error)
alert('Hiba történt a válasz beküldése közben.')
}
}
function nextQuestion() {
if (currentQuestionIndex.value < totalQuestions.value - 1) {
currentQuestionIndex.value++
selectedOption.value = null
showResult.value = false
} else {
finishQuiz()
}
}
async function finishQuiz() {
try {
await quizStore.completeDailyQuiz()
showModal.value = false
alert(`Kvíz befejezve! Szerezttél ${quizStore.userPoints} pontot. Streak: ${quizStore.currentStreak}`)
} catch (error) {
console.error('Failed to complete quiz:', error)
alert('Hiba történt a kvíz befejezése közben.')
}
}
function skipToday() {
quizStore.completeDailyQuiz() // mark as played today
closeModal()
}
</script>
<template>
<div v-if="showModal" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
<div class="relative w-full max-w-2xl rounded-2xl bg-gradient-to-br from-blue-50 to-white shadow-2xl p-6 md:p-8 border border-blue-200">
<!-- Close button -->
<button @click="closeModal" class="absolute top-4 right-4 text-gray-500 hover:text-gray-800 text-2xl">
&times;
</button>
<!-- Header -->
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 mb-4">
<span class="text-3xl">🧠</span>
</div>
<h2 class="text-3xl font-bold text-gray-900">Napi Kvíz</h2>
<p class="text-gray-600 mt-2">Teszteld tudásod és szerezz pontokat!</p>
<div class="mt-4 flex items-center justify-between text-sm text-gray-700">
<div class="flex items-center gap-2">
<span class="font-bold">Pontok:</span>
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full">{{ quizStore.userPoints }}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-bold">Streak:</span>
<span class="bg-amber-100 text-amber-800 px-3 py-1 rounded-full">{{ quizStore.currentStreak }} nap</span>
</div>
</div>
</div>
<!-- Progress bar -->
<div class="mb-8">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span>Kérdés {{ currentQuestionIndex + 1 }} / {{ totalQuestions }}</span>
<span>{{ Math.round(progress) }}%</span>
</div>
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500" :style="{ width: `${progress}%` }"></div>
</div>
</div>
<!-- Question -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-900 mb-6">{{ currentQuestion.question }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
v-for="(option, idx) in currentQuestion.options"
:key="idx"
@click="selectOption(idx)"
class="p-4 text-left rounded-xl border-2 transition-all duration-300"
:class="{
'border-blue-500 bg-blue-50': selectedOption === idx,
'border-gray-300 hover:border-blue-400 hover:bg-blue-50': selectedOption === null && !showResult,
'border-green-500 bg-green-50': showResult && idx === currentQuestion.correctAnswer,
'border-red-300 bg-red-50': showResult && selectedOption === idx && !isCorrect,
}"
:disabled="showResult"
>
<div class="flex items-center">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mr-3"
:class="{
'bg-blue-100 text-blue-800': selectedOption === idx && !showResult,
'bg-green-100 text-green-800': showResult && idx === currentQuestion.correctAnswer,
'bg-red-100 text-red-800': showResult && selectedOption === idx && !isCorrect,
'bg-gray-100 text-gray-800': selectedOption !== idx && !showResult,
}">
{{ String.fromCharCode(65 + idx) }}
</div>
<span class="font-medium">{{ option }}</span>
</div>
</button>
</div>
</div>
<!-- Result & Explanation -->
<div v-if="showResult" class="mb-8 p-5 rounded-xl" :class="isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="isCorrect ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
{{ isCorrect ? '✅' : '❌' }}
</div>
<h4 class="text-xl font-bold" :class="isCorrect ? 'text-green-800' : 'text-red-800'">
{{ isCorrect ? 'Helyes válasz!' : 'Sajnos nem talált!' }}
</h4>
</div>
<p class="text-gray-800">{{ resultExplanation }}</p>
<div class="mt-4 text-sm text-gray-700">
<span class="font-bold">Pontok:</span> {{ isCorrect ? '+10' : '0' }} |
<span class="font-bold">Streak:</span> {{ isCorrect ? 'növelve' : 'nullázva' }}
</div>
</div>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-4">
<button
v-if="!showResult"
@click="skipToday"
class="flex-1 py-3 px-6 rounded-xl border-2 border-gray-300 text-gray-700 font-semibold hover:bg-gray-100 transition"
>
Emlékeztess később
</button>
<button
v-if="showResult && currentQuestionIndex < totalQuestions - 1"
@click="nextQuestion"
class="flex-1 py-3 px-6 rounded-xl bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold hover:opacity-90 transition"
>
Következő kérdés
</button>
<button
v-if="showResult && currentQuestionIndex === totalQuestions - 1"
@click="finishQuiz"
class="flex-1 py-3 px-6 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold hover:opacity-90 transition"
>
Kvíz befejezése
</button>
<button
@click="closeModal"
class="flex-1 py-3 px-6 rounded-xl bg-gray-200 text-gray-800 font-semibold hover:bg-gray-300 transition"
>
Bezárás
</button>
</div>
<!-- Footer note -->
<div class="mt-8 text-center text-sm text-gray-500">
A napi kvíz csak egyszer játszható 24 óránként. Streaked növeléséhez válaszolj helyesen minden nap!
</div>
</div>
</div>
</template>
<style scoped>
/* Additional custom styles if needed */
</style>

View File

@@ -0,0 +1,161 @@
<template>
<div class="max-w-6xl mx-auto p-6">
<h1 class="text-4xl font-bold text-center mb-4 text-slate-900">Welcome to Service Finder</h1>
<p class="text-lg text-slate-600 text-center mb-12 max-w-2xl mx-auto">
Choose your experience based on how you use vehicles. Your selection will customize the dashboard and features.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
<!-- Private Garage Card - Vibrant gradient border & playful -->
<div
class="relative rounded-3xl p-8 cursor-pointer transition-all duration-300 hover:scale-[1.02] active:scale-95 bg-gradient-to-br from-white to-slate-50/80 backdrop-blur-sm border border-slate-200/60"
:class="{
'selected shadow-2xl ring-4 ring-opacity-30 ring-amber-400/50': isPrivateGarage,
'private-garage': true
}"
@click="selectMode('private_garage')"
>
<!-- Vibrant gradient border effect -->
<div v-if="isPrivateGarage" class="absolute -inset-0.5 bg-gradient-to-r from-amber-400 via-orange-400 to-pink-400 rounded-3xl blur-sm opacity-70 -z-10"></div>
<div class="mb-6 p-4 rounded-2xl inline-flex bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-4 text-slate-900">Private Garage</h2>
<p class="text-slate-700 mb-6 leading-relaxed">
Perfect for individual vehicle owners. Track expenses, maintenance, and get personalized recommendations for your personal cars, motorcycles, or recreational vehicles.
</p>
<ul class="mb-8 space-y-3 card-features-private">
<li class="flex items-center text-sm text-slate-700">Personal vehicle management</li>
<li class="flex items-center text-sm text-slate-700">Expense tracking & budgeting</li>
<li class="flex items-center text-sm text-slate-700">Maintenance reminders</li>
<li class="flex items-center text-sm text-slate-700">Fuel efficiency analytics</li>
</ul>
<div class="flex justify-between items-center">
<span class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-amber-100 to-orange-100 text-amber-800 border border-amber-200/60">For Individuals</span>
<button
class="px-6 py-2.5 rounded-lg font-medium transition-all duration-200 bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 active:scale-95 shadow-md hover:shadow-lg"
:class="{ 'ring-2 ring-amber-300 ring-offset-2': isPrivateGarage }"
>
{{ isPrivateGarage ? '✓ Selected' : 'Select' }}
</button>
</div>
</div>
<!-- Corporate Fleet Card - Minimalist sharp design -->
<div
class="relative rounded-3xl p-8 cursor-pointer transition-all duration-300 hover:scale-[1.02] active:scale-95 bg-gradient-to-br from-white to-slate-50/80 backdrop-blur-sm border border-slate-200/60"
:class="{
'selected shadow-2xl ring-4 ring-opacity-30 ring-blue-400/50': isCorporateFleet,
'corporate-fleet': true
}"
@click="selectMode('corporate_fleet')"
>
<!-- Sharp business accent line -->
<div v-if="isCorporateFleet" class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-t-3xl"></div>
<div class="mb-6 p-4 rounded-2xl inline-flex bg-gradient-to-br from-blue-50 to-cyan-50 text-blue-700 border border-blue-200/40">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-4 text-slate-900">Corporate Fleet</h2>
<p class="text-slate-700 mb-6 leading-relaxed">
Designed for fleet managers and businesses. Monitor multiple vehicles, optimize TCO (Total Cost of Ownership), and manage service schedules across your entire fleet.
</p>
<ul class="mb-8 space-y-3 card-features-corporate">
<li class="flex items-center text-sm text-slate-700">Multi-vehicle fleet management</li>
<li class="flex items-center text-sm text-slate-700">TCO & ROI analytics</li>
<li class="flex items-center text-sm text-slate-700">Driver assignment & reporting</li>
<li class="flex items-center text-sm text-slate-700">Bulk service scheduling</li>
</ul>
<div class="flex justify-between items-center">
<span class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-50 to-cyan-50 text-blue-800 border border-blue-200/60">For Businesses</span>
<button
class="px-6 py-2.5 rounded-lg font-medium transition-all duration-200 bg-gradient-to-r from-blue-600 to-cyan-600 text-white hover:from-blue-700 hover:to-cyan-700 active:scale-95 shadow-md hover:shadow-lg"
:class="{ 'ring-2 ring-blue-300 ring-offset-2': isCorporateFleet }"
>
{{ isCorporateFleet ? '✓ Selected' : 'Select' }}
</button>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row justify-between items-center p-6 border-t border-slate-200/60">
<p class="text-sm text-slate-500">
You can change this later from the header menu.
</p>
<button
class="mt-4 md:mt-0 px-8 py-3.5 bg-gradient-to-r from-blue-600 to-cyan-600 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-cyan-700 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center shadow-lg hover:shadow-xl active:scale-95"
@click="continueToDashboard"
:disabled="!mode"
>
Continue to Dashboard
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L12.586 11H5a1 1 0 110-2h7.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { useAppModeStore } from '@/stores/appModeStore'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
const appModeStore = useAppModeStore()
const router = useRouter()
const { mode, isPrivateGarage, isCorporateFleet } = storeToRefs(appModeStore)
function selectMode(newMode) {
appModeStore.setMode(newMode)
}
function continueToDashboard() {
console.log('ProfileSelector: Continuing to dashboard with mode', mode.value)
try {
router.push('/')
console.log('ProfileSelector: Redirect to dashboard successful')
} catch (error) {
console.error('ProfileSelector: Failed to redirect to dashboard:', error)
}
}
</script>
<style scoped>
/* Keep only pseudo-element and media query styles */
.card-features-private li::before,
.card-features-corporate li::before {
content: '✓';
margin-right: 0.5rem;
font-weight: bold;
}
.card-features-private li::before {
color: #f59e0b; /* amber-500 */
}
.card-features-corporate li::before {
color: #06b6d4; /* cyan-500 */
}
/* Responsive adjustments */
@media (max-width: 768px) {
.grid {
gap: 1.5rem;
}
.rounded-3xl.p-8 {
padding: 1.5rem;
}
.flex.flex-col.md\:flex-row {
flex-direction: column;
gap: 1rem;
}
}
</style>

View File

@@ -0,0 +1,245 @@
<script setup>
import { ref } from 'vue'
import { useExpenseStore } from '@/stores/expenseStore'
import { useGarageStore } from '@/stores/garageStore'
const emit = defineEmits(['close'])
const expenseStore = useExpenseStore()
const garageStore = useGarageStore()
// Use selected vehicle from garage store, or default to first vehicle
const selectedAssetId = ref(garageStore.selectedVehicle?.id || garageStore.vehicles[0]?.id || '')
const amount = ref('')
const category = ref('fuel')
const date = ref(new Date().toISOString().split('T')[0]) // today
const description = ref('')
const mileage = ref('')
const isLoading = ref(false)
const showDraftLimitModal = ref(false)
const handleSubmit = async () => {
if (!selectedAssetId.value) {
alert('Nincs kiválasztott jármű. Kérjük, először adj hozzá egy járművet.')
return
}
if (!amount.value || !date.value) {
alert('Kérjük, töltsd ki a kötelező mezőket.')
return
}
isLoading.value = true
try {
const expenseData = {
asset_id: selectedAssetId.value,
cost_type: category.value, // fuel, service, tax, insurance
amount_local: parseFloat(amount.value),
currency_local: 'HUF', // default, could be dynamic
date: new Date(date.value).toISOString(),
description: description.value,
mileage_at_cost: mileage.value ? parseInt(mileage.value) : null,
data: {}
}
await expenseStore.createExpense(expenseData)
// Success
alert('Költség sikeresen mentve!')
// Reset form
amount.value = ''
category.value = 'fuel'
date.value = new Date().toISOString().split('T')[0]
description.value = ''
mileage.value = ''
// Close modal
emit('close')
} catch (error) {
console.error('Error saving expense:', error)
if (error.message === 'DRAFT_LIMIT_REACHED') {
// Show custom modal for draft limit
showDraftLimitModal.value = true
} else {
alert(`Hiba történt a mentés során: ${expenseStore.error || error.message}`)
}
} finally {
isLoading.value = false
}
}
const closeModal = () => {
emit('close')
}
const closeDraftLimitModal = () => {
showDraftLimitModal.value = false
}
const openEditVehicle = () => {
// TODO: Implement opening edit vehicle modal
// For now, just close the draft limit modal and maybe show a message
alert('Edit vehicle functionality to be implemented')
closeDraftLimitModal()
}
</script>
<template>
<!-- Modal Backdrop -->
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
<!-- Modal Container -->
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<!-- Modal Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">Költség / Üzemanyag hozzáadása</h2>
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-6">
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- Asset selection (if multiple vehicles) -->
<div v-if="garageStore.vehicles.length > 0">
<label class="block text-sm font-medium text-gray-700 mb-2">Jármű</label>
<select
v-model="selectedAssetId"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option v-for="vehicle in garageStore.vehicles" :key="vehicle.id" :value="vehicle.id">
{{ vehicle.name || vehicle.license_plate || vehicle.vin }}
</option>
</select>
</div>
<div v-else class="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
Nincs még járműved. Először adj hozzá egy járművet a "Jármű Hozzáadása" gombbal.
</div>
<!-- Amount -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Összeg (HUF)</label>
<input
v-model="amount"
type="number"
step="0.01"
min="0"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="0.00"
/>
</div>
<!-- Category -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kategória</label>
<select
v-model="category"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option value="fuel">Üzemanyag</option>
<option value="service">Szerviz / Karbantartás</option>
<option value="tax">Adó / Díj</option>
<option value="insurance">Biztosítás</option>
<option value="parking">Parkolás</option>
<option value="toll">Útdíj</option>
<option value="other">Egyéb</option>
</select>
</div>
<!-- Date -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Dátum</label>
<input
v-model="date"
type="date"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
/>
</div>
<!-- Mileage (optional) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kilométeróra állása (opcionális)</label>
<input
v-model="mileage"
type="number"
min="0"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="pl. 123456"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Leírás (opcionális)</label>
<textarea
v-model="description"
rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Pl.: Tankolás, olajcsere..."
></textarea>
</div>
<!-- Buttons -->
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeModal"
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
>
Mégse
</button>
<button
type="submit"
:disabled="isLoading || !selectedAssetId"
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isLoading">Mentés...</span>
<span v-else>Mentés</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Draft Limit Modal -->
<div v-if="showDraftLimitModal" class="fixed inset-0 z-[200] flex items-center justify-center bg-black bg-opacity-70 p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<div class="p-6">
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-yellow-100 text-yellow-600 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-2xl font-bold text-gray-900 mb-2">You have reached the limit for unregistered vehicles! 🔒</h3>
<p class="text-gray-600 mb-6">
Please edit this vehicle and provide the VIN (Chassis Number) or exact Catalog ID to unlock full expense tracking.
</p>
</div>
<div class="flex gap-3">
<button
@click="closeDraftLimitModal"
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
>
Cancel
</button>
<button
@click="openEditVehicle"
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
>
Edit Vehicle
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Additional custom styles if needed */
</style>

View File

@@ -0,0 +1,404 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useGarageStore } from '../../stores/garageStore'
import { useAuthStore } from '../../stores/authStore'
const emit = defineEmits(['close'])
const garageStore = useGarageStore()
const authStore = useAuthStore()
// Form fields
const make = ref('')
const model = ref('')
const generation = ref('')
const engine = ref('')
const engineId = ref(null) // NEW: Store the engine/catalog ID
const licensePlate = ref('')
const year = ref('')
const fuelType = ref('petrol')
const isLoading = ref(false)
const error = ref(null)
// Catalog data
const makes = ref([])
const models = ref([])
const generations = ref([])
const engines = ref([])
// Loading states
const loadingMakes = ref(false)
const loadingModels = ref(false)
const loadingGenerations = ref(false)
const loadingEngines = ref(false)
// Search filters
const makeSearch = ref('')
const modelSearch = ref('')
const generationSearch = ref('')
const engineSearch = ref('')
// Filtered lists based on search
const filteredMakes = ref([])
const filteredModels = ref([])
const filteredGenerations = ref([])
const filteredEngines = ref([])
// Fetch makes on component mount
onMounted(async () => {
await fetchMakes()
})
// Watch for search changes
watch(makeSearch, (val) => {
filteredMakes.value = makes.value.filter(m =>
m.toLowerCase().includes(val.toLowerCase())
)
})
watch(modelSearch, (val) => {
filteredModels.value = models.value.filter(m =>
m.toLowerCase().includes(val.toLowerCase())
)
})
watch(generationSearch, (val) => {
filteredGenerations.value = generations.value.filter(g =>
g.toLowerCase().includes(val.toLowerCase())
)
})
watch(engineSearch, (val) => {
filteredEngines.value = engines.value.filter(e =>
e.variant.toLowerCase().includes(val.toLowerCase())
)
})
// Watch for make selection to fetch models
watch(make, async (newMake) => {
if (newMake) {
await fetchModels(newMake)
model.value = ''
generation.value = ''
engine.value = ''
engineId.value = null
generations.value = []
engines.value = []
} else {
models.value = []
generations.value = []
engines.value = []
}
})
// Watch for model selection to fetch generations
watch(model, async (newModel) => {
if (newModel && make.value) {
await fetchGenerations(make.value, newModel)
generation.value = ''
engine.value = ''
engineId.value = null
engines.value = []
} else {
generations.value = []
engines.value = []
}
})
// Watch for generation selection to fetch engines
watch(generation, async (newGen) => {
if (newGen && make.value && model.value) {
await fetchEngines(make.value, model.value, newGen)
engine.value = ''
engineId.value = null
} else {
engines.value = []
}
})
// Watch for engine selection to auto-fill fuel type and capture ID
watch(engine, (newEngine) => {
if (newEngine) {
const selectedEngine = engines.value.find(e => e.variant === newEngine)
if (selectedEngine) {
// Store the engine ID for API call
engineId.value = selectedEngine.id
// Auto-fill fuel type if available
if (selectedEngine.fuel_type) {
fuelType.value = selectedEngine.fuel_type.toLowerCase()
}
}
} else {
engineId.value = null
}
})
// API calls
async function fetchMakes() {
loadingMakes.value = true
try {
const token = authStore.token
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
const response = await fetch(`${apiBaseUrl}/catalog/makes`, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
if (!response.ok) throw new Error(`Failed to fetch makes: ${response.status}`)
makes.value = await response.json()
filteredMakes.value = makes.value
} catch (err) {
console.error('Error fetching makes:', err)
error.value = 'Could not load makes. Please try again.'
} finally {
loadingMakes.value = false
}
}
async function fetchModels(makeName) {
loadingModels.value = true
try {
const token = authStore.token
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
const response = await fetch(`${apiBaseUrl}/catalog/models?make=${encodeURIComponent(makeName)}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
if (!response.ok) throw new Error(`Failed to fetch models: ${response.status}`)
models.value = await response.json()
filteredModels.value = models.value
} catch (err) {
console.error('Error fetching models:', err)
error.value = 'Could not load models for this make.'
} finally {
loadingModels.value = false
}
}
async function fetchGenerations(makeName, modelName) {
loadingGenerations.value = true
try {
const token = authStore.token
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
const response = await fetch(`${apiBaseUrl}/catalog/generations?make=${encodeURIComponent(makeName)}&model=${encodeURIComponent(modelName)}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
if (!response.ok) throw new Error(`Failed to fetch generations: ${response.status}`)
generations.value = await response.json()
filteredGenerations.value = generations.value
} catch (err) {
console.error('Error fetching generations:', err)
error.value = 'Could not load generations for this model.'
} finally {
loadingGenerations.value = false
}
}
async function fetchEngines(makeName, modelName, genName) {
loadingEngines.value = true
try {
const token = authStore.token
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
const response = await fetch(`${apiBaseUrl}/catalog/engines?make=${encodeURIComponent(makeName)}&model=${encodeURIComponent(modelName)}&gen=${encodeURIComponent(genName)}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
if (!response.ok) throw new Error(`Failed to fetch engines: ${response.status}`)
const engineData = await response.json()
engines.value = engineData.map(e => ({
variant: e.variant,
fuel_type: e.fuel_type,
id: e.id
}))
filteredEngines.value = engines.value
} catch (err) {
console.error('Error fetching engines:', err)
error.value = 'Could not load engines for this generation.'
} finally {
loadingEngines.value = false
}
}
const handleSubmit = async () => {
error.value = null
isLoading.value = true
try {
// Prepare vehicle data for the API
const vehicleData = {
make: make.value,
model: model.value,
licensePlate: licensePlate.value,
year: parseInt(year.value) || new Date().getFullYear(),
fuelType: fuelType.value,
generation: generation.value,
engine: engine.value,
// Include the catalog ID (engine ID) for API
catalogId: engineId.value,
// Include organization ID (use active organization ID)
organizationId: authStore.activeOrgId
}
// Call the garage store to add vehicle
await garageStore.addVehicle(vehicleData)
// Show success message
alert('Sikeres mentés! Jármű hozzáadva.')
// Reset form
make.value = ''
model.value = ''
generation.value = ''
engine.value = ''
engineId.value = null
licensePlate.value = ''
year.value = ''
fuelType.value = 'petrol'
makes.value = []
models.value = []
generations.value = []
engines.value = []
// Close modal
emit('close')
} catch (err) {
console.error('Error adding vehicle:', err)
error.value = err.message || 'Ismeretlen hiba történt'
alert(`Hiba: ${error.value}`)
} finally {
isLoading.value = false
}
}
const closeModal = () => {
emit('close')
}
</script>
<template>
<!-- Modal Backdrop -->
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
<!-- Modal Container -->
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<!-- Modal Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">Jármű hozzáadása</h2>
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-6">
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- Make -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Gyártó</label>
<input
v-model="make"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
placeholder="Pl.: Toyota"
/>
</div>
<!-- Model -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Modell</label>
<input
v-model="model"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
placeholder="Pl.: Corolla"
/>
</div>
<!-- License Plate -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Rendszám</label>
<input
v-model="licensePlate"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
placeholder="Pl.: ABC-123"
/>
</div>
<!-- Year and Fuel Type in a grid -->
<div class="grid grid-cols-2 gap-4">
<!-- Year -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Évjárat</label>
<input
v-model="year"
type="number"
min="1900"
:max="new Date().getFullYear() + 1"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
placeholder="2023"
/>
</div>
<!-- Fuel Type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Üzemanyag típus</label>
<select
v-model="fuelType"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900"
>
<option value="petrol">Benzin</option>
<option value="diesel">Dízel</option>
<option value="electric">Elektromos</option>
<option value="hybrid">Hibrid</option>
<option value="lpg">LPG</option>
<option value="other">Egyéb</option>
</select>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{{ error }}
</div>
<!-- Buttons -->
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeModal"
:disabled="isLoading"
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Mégse
</button>
<button
type="submit"
:disabled="isLoading"
class="flex-1 px-4 py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
<span v-if="isLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-white border-r-transparent mr-2"></span>
{{ isLoading ? 'Feldolgozás...' : 'Jármű hozzáadása' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
/* Additional custom styles if needed */
</style>

View File

@@ -0,0 +1,135 @@
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['close'])
const serviceType = ref('maintenance')
const location = ref('')
const urgency = ref('medium')
const handleSubmit = () => {
// In a real app, you would call an API here
console.log('Service search submitted:', {
serviceType: serviceType.value,
location: location.value,
urgency: urgency.value
})
// Show success message
alert('Szerviz keresés elindítva! Hamarosan értesítünk.')
// Reset form
serviceType.value = 'maintenance'
location.value = ''
urgency.value = 'medium'
// Close modal
emit('close')
}
const closeModal = () => {
emit('close')
}
</script>
<template>
<!-- Modal Backdrop -->
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
<!-- Modal Container -->
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<!-- Modal Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">Szerviz Keresése</h2>
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-6">
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- Service Type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Szerviz típusa</label>
<select
v-model="serviceType"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option value="maintenance">Általános karbantartás</option>
<option value="repair">Javítás</option>
<option value="diagnostic">Diagnosztika</option>
<option value="tire">Gumiszerviz</option>
<option value="oil">Olajcsere</option>
<option value="brake">Fékrendszer</option>
<option value="other">Egyéb</option>
</select>
</div>
<!-- Location -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Helyszín (város/irányítószám)</label>
<input
v-model="location"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Pl.: Budapest"
/>
</div>
<!-- Urgency -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Sürgősség</label>
<div class="flex gap-4">
<label class="flex items-center">
<input v-model="urgency" type="radio" value="low" class="mr-2">
<span class="text-gray-700">Alacsony</span>
</label>
<label class="flex items-center">
<input v-model="urgency" type="radio" value="medium" class="mr-2">
<span class="text-gray-700">Közepes</span>
</label>
<label class="flex items-center">
<input v-model="urgency" type="radio" value="high" class="mr-2">
<span class="text-gray-700">Magas</span>
</label>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Részletes leírás (opcionális)</label>
<textarea
rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Pl.: Motorhiba, féknyikorgás..."
></textarea>
</div>
<!-- Buttons -->
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeModal"
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
>
Mégse
</button>
<button
type="submit"
class="flex-1 px-4 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition"
>
Szerviz keresése
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
/* Additional custom styles if needed */
</style>

View File

@@ -0,0 +1,115 @@
<script setup>
import { ref } from 'vue'
import AddExpenseModal from './AddExpenseModal.vue'
import SmartVehicleRegistration from './SmartVehicleRegistration.vue'
import FindServiceModal from './FindServiceModal.vue'
const isOpen = ref(false)
const showExpenseModal = ref(false)
const showSmartVehicleRegistration = ref(false)
const showServiceModal = ref(false)
const toggleMenu = () => {
isOpen.value = !isOpen.value
}
const openExpenseModal = () => {
showExpenseModal.value = true
isOpen.value = false
}
const openSmartVehicleRegistration = () => {
showSmartVehicleRegistration.value = true
isOpen.value = false
}
const openServiceModal = () => {
showServiceModal.value = true
isOpen.value = false
}
const closeExpenseModal = () => {
showExpenseModal.value = false
}
const closeSmartVehicleRegistration = () => {
showSmartVehicleRegistration.value = false
}
const closeServiceModal = () => {
showServiceModal.value = false
}
</script>
<template>
<!-- Floating Action Button -->
<div class="fixed bottom-6 right-6 z-50 flex flex-col items-end">
<!-- Action Menu (shown when open) -->
<div v-if="isOpen" class="mb-4 space-y-3">
<!-- Add Expense Button -->
<button
@click="openExpenseModal"
class="flex items-center justify-end gap-3 bg-blue-600 hover:bg-blue-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
>
<span class="text-sm font-semibold">Költség / Üzemanyag</span>
<div class="w-10 h-10 flex items-center justify-center bg-blue-800 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
</button>
<!-- Find Service Button -->
<button
@click="openServiceModal"
class="flex items-center justify-end gap-3 bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
>
<span class="text-sm font-semibold">Szerviz Keresése</span>
<div class="w-10 h-10 flex items-center justify-center bg-green-800 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</button>
<!-- Add Vehicle Button -->
<button
@click="openSmartVehicleRegistration"
class="flex items-center justify-end gap-3 bg-purple-600 hover:bg-purple-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
>
<span class="text-sm font-semibold">Jármű Hozzáadása</span>
<div class="w-10 h-10 flex items-center justify-center bg-purple-800 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
</button>
</div>
<!-- Main FAB Button -->
<button
@click="toggleMenu"
class="w-14 h-14 flex items-center justify-center bg-blue-700 hover:bg-blue-800 text-white rounded-full shadow-xl transition-all duration-200 transform hover:scale-110"
:class="{ 'rotate-45': isOpen }"
>
<svg v-if="!isOpen" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modals -->
<AddExpenseModal v-if="showExpenseModal" @close="closeExpenseModal" />
<SmartVehicleRegistration v-if="showSmartVehicleRegistration" @close="closeSmartVehicleRegistration" />
<FindServiceModal v-if="showServiceModal" @close="closeServiceModal" />
</template>
<style scoped>
/* Smooth transitions */
button {
transition: all 0.2s ease-in-out;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
<template>
<div class="analytics-dashboard">
<!-- Header with Mode Toggle -->
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 p-6 bg-gradient-to-r from-gray-50 to-white rounded-2xl shadow-sm border border-gray-200">
<div>
<h1 class="text-3xl font-bold text-gray-900">Vehicle Analytics & TCO Dashboard</h1>
<p class="text-gray-600 mt-2">
{{ isPrivateGarage ? 'Personal driving insights and fun achievements' : 'Corporate fleet performance and cost optimization' }}
</p>
</div>
<div class="mt-4 md:mt-0">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<span class="mr-3 text-sm font-medium text-gray-700">View Mode:</span>
<div class="relative inline-block w-64">
<div class="bg-gray-100 rounded-xl p-1 flex">
<button
@click="setMode('private_garage')"
:class="[
'flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200',
isPrivateGarage
? 'bg-white shadow text-gray-900'
: 'text-gray-600 hover:text-gray-900'
]"
>
<div class="flex items-center justify-center">
<span class="mr-2">🎮</span>
<span>Fun Stats</span>
</div>
</button>
<button
@click="setMode('corporate_fleet')"
:class="[
'flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200',
isCorporateFleet
? 'bg-white shadow text-gray-900'
: 'text-gray-600 hover:text-gray-900'
]"
>
<div class="flex items-center justify-center">
<span class="mr-2">📊</span>
<span>Business BI</span>
</div>
</button>
</div>
</div>
</div>
<button
@click="toggleMode"
class="px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg flex items-center"
>
<span class="mr-2">🔄</span>
Switch to {{ isPrivateGarage ? 'Business' : 'Fun' }} View
</button>
</div>
<div class="mt-4 text-sm text-gray-500 flex items-center">
<div class="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
<span>Live data updated {{ lastUpdated }}</span>
<button @click="refreshData" class="ml-4 text-blue-600 hover:text-blue-800 flex items-center">
<span class="mr-1"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Mode Indicator -->
<div class="mb-6">
<div v-if="isPrivateGarage" class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-blue-100 to-indigo-100 text-blue-800">
<span class="mr-2">🎯</span>
<span class="font-medium">Private Garage Mode</span>
<span class="ml-2 text-sm">Personal insights and achievements</span>
</div>
<div v-else class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-green-100 to-emerald-100 text-green-800">
<span class="mr-2">🏢</span>
<span class="font-medium">Corporate Fleet Mode</span>
<span class="ml-2 text-sm">Business intelligence and TCO analysis</span>
</div>
</div>
<!-- Dynamic Component -->
<div class="mt-6">
<component :is="currentComponent" />
</div>
<!-- Footer Notes -->
<div class="mt-12 pt-6 border-t border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-gray-50 p-4 rounded-xl">
<h4 class="font-semibold text-gray-800 mb-2">📈 Data Sources</h4>
<p class="text-sm text-gray-600">Vehicle telemetry, fuel receipts, maintenance records, and insurance data aggregated in real-time.</p>
</div>
<div class="bg-gray-50 p-4 rounded-xl">
<h4 class="font-semibold text-gray-800 mb-2">🎯 Key Metrics</h4>
<p class="text-sm text-gray-600">TCO (Total Cost of Ownership), Cost per km, Fuel efficiency, Utilization rate, and Environmental impact.</p>
</div>
<div class="bg-gray-50 p-4 rounded-xl">
<h4 class="font-semibold text-gray-800 mb-2">🔄 Auto-Sync</h4>
<p class="text-sm text-gray-600">Data updates every 24 hours. Manual refresh available. Historical data retained for 36 months.</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, shallowRef } from 'vue'
import { useAppModeStore } from '@/stores/appModeStore'
import FunStats from './FunStats.vue'
import BusinessBI from './BusinessBI.vue'
const appModeStore = useAppModeStore()
const { mode, isPrivateGarage, isCorporateFleet, toggleMode, setMode } = appModeStore
const lastUpdated = ref('just now')
const isLoading = ref(false)
const currentComponent = shallowRef(FunStats)
// Watch mode changes and update component
import { watch } from 'vue'
watch(() => mode.value, (newMode) => {
if (newMode === 'private_garage') {
currentComponent.value = FunStats
} else {
currentComponent.value = BusinessBI
}
}, { immediate: true })
const refreshData = () => {
isLoading.value = true
// Simulate API call
setTimeout(() => {
const now = new Date()
lastUpdated.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
isLoading.value = false
// Show success notification
const event = new CustomEvent('show-toast', {
detail: {
message: 'Analytics data refreshed successfully',
type: 'success'
}
})
window.dispatchEvent(event)
}, 800)
}
</script>
<style scoped>
.analytics-dashboard {
font-family: 'Inter', sans-serif;
}
/* Smooth transitions for mode switching */
.component-enter-active,
.component-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.component-enter-from,
.component-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>

View File

@@ -0,0 +1,385 @@
<template>
<div class="business-bi">
<h2 class="text-2xl font-bold text-gray-800 mb-6">📊 Business Intelligence Dashboard</h2>
<!-- Key Metrics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Fleet Size</p>
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.fleetSize }}</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<span class="text-2xl text-blue-600">🚗</span>
</div>
</div>
<p class="text-sm text-gray-500 mt-2">Active vehicles</p>
</div>
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Total Monthly Cost</p>
<p class="text-3xl font-bold text-gray-800">{{ formatNumber(businessMetrics.totalMonthlyCost) }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<span class="text-2xl text-green-600">💰</span>
</div>
</div>
<p class="text-sm text-gray-500 mt-2">All expenses combined</p>
</div>
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Avg Cost per Km</p>
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.averageCostPerKm.toFixed(2) }}</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<span class="text-2xl text-purple-600">📈</span>
</div>
</div>
<p class="text-sm text-gray-500 mt-2">Operating efficiency</p>
</div>
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Utilization Rate</p>
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.utilizationRate }}%</p>
</div>
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center">
<span class="text-2xl text-amber-600"></span>
</div>
</div>
<p class="text-sm text-gray-500 mt-2">Fleet activity</p>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- Monthly Costs Chart -->
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Monthly Cost Breakdown (Last 6 Months)</h3>
<div class="h-80">
<canvas ref="monthlyCostsChart"></canvas>
</div>
<div class="mt-4 text-sm text-gray-500">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-3 h-3 bg-blue-500 rounded-full mr-2"></div>
<span>Maintenance</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span>Fuel</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 bg-amber-500 rounded-full mr-2"></div>
<span>Insurance</span>
</div>
</div>
</div>
</div>
<!-- Fuel Efficiency Trend Chart -->
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Fuel Efficiency Trend (km per liter)</h3>
<div class="h-80">
<canvas ref="fuelEfficiencyChart"></canvas>
</div>
<div class="mt-4 text-sm text-gray-500">
<p>Average: <span class="font-semibold">{{ averageFuelEfficiency.toFixed(1) }} km/L</span></p>
<p class="text-green-600"> {{ ((averageFuelEfficiency - 12) / 12 * 100).toFixed(1) }}% improvement vs industry average</p>
</div>
</div>
</div>
<!-- Cost per Km and TCO Analysis -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- Cost per Km Chart -->
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Cost per Kilometer Trend</h3>
<div class="h-64">
<canvas ref="costPerKmChart"></canvas>
</div>
<div class="mt-4 text-sm text-gray-500">
<p>Average cost: <span class="font-semibold">{{ averageCostPerKm.toFixed(2) }}/km</span></p>
<p>Target: <span class="font-semibold">0.38/km</span></p>
</div>
</div>
<!-- TCO Breakdown -->
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Total Cost of Ownership (TCO) Breakdown</h3>
<div class="h-64">
<canvas ref="tcoChart"></canvas>
</div>
<div class="mt-4 text-sm text-gray-500">
<p>Annual TCO: <span class="font-semibold">{{ formatNumber(businessMetrics.totalMonthlyCost * 12) }}</span></p>
<p>Per vehicle: <span class="font-semibold">{{ formatNumber(Math.round(businessMetrics.totalMonthlyCost * 12 / businessMetrics.fleetSize)) }}/year</span></p>
</div>
</div>
</div>
<!-- Data Table -->
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Monthly Performance Details</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr class="bg-gray-50">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Month</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Maintenance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fuel</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Insurance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cost/km</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Efficiency</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(month, index) in monthlyCosts" :key="month.month">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ month.month }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ month.maintenance }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ month.fuel }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ month.insurance }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">{{ month.total }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ costPerKmTrends[index]?.cost.toFixed(2) || '0.00' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ fuelEfficiencyTrends[index]?.efficiency.toFixed(1) || '0.0' }} km/L</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Insights Panel -->
<div class="mt-8 bg-gradient-to-r from-gray-800 to-gray-900 rounded-2xl p-6 text-white shadow-lg">
<div class="flex items-center mb-4">
<div class="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center mr-4">
<span class="text-xl">💡</span>
</div>
<div>
<h3 class="text-xl font-bold">Business Insights</h3>
<p class="text-gray-300">AI-powered recommendations</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div class="bg-white/10 p-4 rounded-xl">
<h4 class="font-semibold mb-2">💰 Cost Optimization</h4>
<p class="text-sm text-gray-200">Maintenance costs are {{ getCostComparison() }} than industry average. Consider preventive maintenance scheduling to reduce unexpected repairs.</p>
</div>
<div class="bg-white/10 p-4 rounded-xl">
<h4 class="font-semibold mb-2"> Fuel Efficiency</h4>
<p class="text-sm text-gray-200">Your fleet is {{ getEfficiencyComparison() }} efficient than benchmark. Continue driver training programs for optimal performance.</p>
</div>
<div class="bg-white/10 p-4 rounded-xl">
<h4 class="font-semibold mb-2">📅 Utilization Rate</h4>
<p class="text-sm text-gray-200">{{ businessMetrics.utilizationRate }}% utilization is good. Consider dynamic routing to increase to 85% target.</p>
</div>
<div class="bg-white/10 p-4 rounded-xl">
<h4 class="font-semibold mb-2">🔧 Downtime Management</h4>
<p class="text-sm text-gray-200">{{ businessMetrics.downtimeHours }} hours/month downtime. Predictive maintenance could reduce this by 30%.</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAnalyticsStore } from '@/stores/analyticsStore'
import { storeToRefs } from 'pinia'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const analyticsStore = useAnalyticsStore()
const { monthlyCosts, fuelEfficiencyTrends, costPerKmTrends, businessMetrics, averageFuelEfficiency, averageCostPerKm } = storeToRefs(analyticsStore)
const monthlyCostsChart = ref(null)
const fuelEfficiencyChart = ref(null)
const costPerKmChart = ref(null)
const tcoChart = ref(null)
let chartInstances = []
const formatNumber = (num) => {
return new Intl.NumberFormat('en-US').format(num)
}
const getCostComparison = () => {
const avgMaintenance = monthlyCosts.value.reduce((sum, month) => sum + month.maintenance, 0) / monthlyCosts.value.length
return avgMaintenance > 500 ? 'higher' : avgMaintenance < 400 ? 'lower' : 'similar'
}
const getEfficiencyComparison = () => {
return averageFuelEfficiency.value > 13 ? 'more' : averageFuelEfficiency.value < 12 ? 'less' : 'equally'
}
onMounted(() => {
// Monthly Costs Chart (Stacked Bar)
if (monthlyCostsChart.value) {
const ctx = monthlyCostsChart.value.getContext('2d')
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: monthlyCosts.value.map(m => m.month),
datasets: [
{
label: 'Maintenance',
data: monthlyCosts.value.map(m => m.maintenance),
backgroundColor: '#3b82f6',
stack: 'Stack 0',
},
{
label: 'Fuel',
data: monthlyCosts.value.map(m => m.fuel),
backgroundColor: '#10b981',
stack: 'Stack 0',
},
{
label: 'Insurance',
data: monthlyCosts.value.map(m => m.insurance),
backgroundColor: '#f59e0b',
stack: 'Stack 0',
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: 'Cost (€)'
}
}
}
}
})
chartInstances.push(chart)
}
// Fuel Efficiency Chart (Line)
if (fuelEfficiencyChart.value) {
const ctx = fuelEfficiencyChart.value.getContext('2d')
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: fuelEfficiencyTrends.value.map(m => m.month),
datasets: [
{
label: 'Fuel Efficiency (km/L)',
data: fuelEfficiencyTrends.value.map(m => m.efficiency),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
fill: true,
tension: 0.4,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false,
title: {
display: true,
text: 'km per liter'
}
}
}
}
})
chartInstances.push(chart)
}
// Cost per Km Chart (Line)
if (costPerKmChart.value) {
const ctx = costPerKmChart.value.getContext('2d')
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: costPerKmTrends.value.map(m => m.month),
datasets: [
{
label: 'Cost per Kilometer (€)',
data: costPerKmTrends.value.map(m => m.cost),
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true,
tension: 0.4,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false,
title: {
display: true,
text: '€ per km'
}
}
}
}
})
chartInstances.push(chart)
}
// TCO Chart (Doughnut)
if (tcoChart.value) {
const ctx = tcoChart.value.getContext('2d')
const totalMaintenance = monthlyCosts.value.reduce((sum, month) => sum + month.maintenance, 0)
const totalFuel = monthlyCosts.value.reduce((sum, month) => sum + month.fuel, 0)
const totalInsurance = monthlyCosts.value.reduce((sum, month) => sum + month.insurance, 0)
const chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Maintenance', 'Fuel', 'Insurance'],
datasets: [
{
data: [totalMaintenance, totalFuel, totalInsurance],
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b'],
borderWidth: 2,
borderColor: '#ffffff',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
}
}
}
})
chartInstances.push(chart)
}
})
onUnmounted(() => {
chartInstances.forEach(chart => chart.destroy())
chartInstances = []
})
</script>
<style scoped>
.business-bi {
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="fun-stats">
<h2 class="text-2xl font-bold text-gray-800 mb-6">🎮 Fun Stats & Achievements</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Moon Trip Card -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-2xl p-6 shadow-lg border border-blue-200">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mr-4">
<span class="text-2xl">🌙</span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">Moon Trip</h3>
<p class="text-sm text-gray-600">Distance traveled</p>
</div>
</div>
<div class="text-center py-4">
<div class="text-4xl font-bold text-blue-700">{{ funFacts.moonTrips }}</div>
<p class="text-gray-600 mt-2">trips to the Moon</p>
</div>
<p class="text-sm text-gray-500 text-center">
You've driven <span class="font-semibold">{{ formatNumber(funFacts.totalKmDriven) }} km</span> - that's {{ funFacts.moonTrips }} trip{{ funFacts.moonTrips !== 1 ? 's' : '' }} to the Moon!
</p>
</div>
<!-- Earth Circuits Card -->
<div class="bg-gradient-to-br from-green-50 to-emerald-100 rounded-2xl p-6 shadow-lg border border-green-200">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-4">
<span class="text-2xl">🌍</span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">Earth Circuits</h3>
<p class="text-sm text-gray-600">Around the world</p>
</div>
</div>
<div class="text-center py-4">
<div class="text-4xl font-bold text-green-700">{{ funFacts.earthCircuits }}</div>
<p class="text-gray-600 mt-2">times around Earth</p>
</div>
<p class="text-sm text-gray-500 text-center">
Equivalent to {{ funFacts.earthCircuits }} circuit{{ funFacts.earthCircuits !== 1 ? 's' : '' }} around the equator!
</p>
</div>
<!-- Trees Saved Card -->
<div class="bg-gradient-to-br from-amber-50 to-orange-100 rounded-2xl p-6 shadow-lg border border-amber-200">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center mr-4">
<span class="text-2xl">🌳</span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">Trees Saved</h3>
<p class="text-sm text-gray-600">Environmental impact</p>
</div>
</div>
<div class="text-center py-4">
<div class="text-4xl font-bold text-amber-700">{{ funFacts.totalTreesSaved }}</div>
<p class="text-gray-600 mt-2">trees preserved</p>
</div>
<p class="text-sm text-gray-500 text-center">
Your efficient driving saved {{ funFacts.totalTreesSaved }} tree{{ funFacts.totalTreesSaved !== 1 ? 's' : '' }} from CO₂ emissions!
</p>
</div>
<!-- CO₂ Saved Card -->
<div class="bg-gradient-to-br from-purple-50 to-pink-100 rounded-2xl p-6 shadow-lg border border-purple-200">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mr-4">
<span class="text-2xl"></span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">CO₂ Saved</h3>
<p class="text-sm text-gray-600">Carbon footprint</p>
</div>
</div>
<div class="text-center py-4">
<div class="text-4xl font-bold text-purple-700">{{ funFacts.totalCo2Saved }}</div>
<p class="text-gray-600 mt-2">tons of CO₂</p>
</div>
<p class="text-sm text-gray-500 text-center">
That's like taking {{ Math.round(funFacts.totalCo2Saved * 1.8) }} cars off the road for a year!
</p>
</div>
<!-- Money Saved Card -->
<div class="bg-gradient-to-br from-cyan-50 to-teal-100 rounded-2xl p-6 shadow-lg border border-cyan-200">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-cyan-100 rounded-xl flex items-center justify-center mr-4">
<span class="text-2xl">💰</span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">Money Saved</h3>
<p class="text-sm text-gray-600">Smart driving pays off</p>
</div>
</div>
<div class="text-center py-4">
<div class="text-4xl font-bold text-cyan-700">€{{ formatNumber(funFacts.totalMoneySaved) }}</div>
<p class="text-gray-600 mt-2">total savings</p>
</div>
<p class="text-sm text-gray-500 text-center">
Compared to average drivers, you saved €{{ formatNumber(funFacts.totalMoneySaved) }}!
</p>
</div>
<!-- Fuel Efficiency Card -->
<div class="bg-gradient-to-br from-rose-50 to-red-100 rounded-2xl p-6 shadow-lg border border-rose-200">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-rose-100 rounded-xl flex items-center justify-center mr-4">
<span class="text-2xl">⛽</span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">Fuel Efficiency</h3>
<p class="text-sm text-gray-600">Your average</p>
</div>
</div>
<div class="text-center py-4">
<div class="text-4xl font-bold text-rose-700">{{ averageFuelEfficiency.toFixed(1) }}</div>
<p class="text-gray-600 mt-2">km per liter</p>
</div>
<p class="text-sm text-gray-500 text-center">
{{ getEfficiencyMessage(averageFuelEfficiency) }}
</p>
</div>
</div>
<!-- Fun Fact of the Day -->
<div class="mt-8 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 text-white shadow-lg">
<div class="flex items-center">
<div class="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center mr-4">
<span class="text-xl">💡</span>
</div>
<div>
<h3 class="text-xl font-bold">Fun Fact of the Day</h3>
<p class="text-indigo-100">Did you know?</p>
</div>
</div>
<p class="mt-4 text-lg">
If every driver in your city achieved your fuel efficiency, we'd save enough CO₂ to fill {{ Math.round(funFacts.totalCo2Saved * 100) }} hot air balloons every year!
</p>
</div>
</div>
</template>
<script setup>
import { useAnalyticsStore } from '@/stores/analyticsStore'
import { storeToRefs } from 'pinia'
const analyticsStore = useAnalyticsStore()
const { funFacts, averageFuelEfficiency } = storeToRefs(analyticsStore)
const formatNumber = (num) => {
return new Intl.NumberFormat('en-US').format(num)
}
const getEfficiencyMessage = (efficiency) => {
if (efficiency > 15) return "Outstanding! You're among the top 5% most efficient drivers."
if (efficiency > 12) return "Great job! You're more efficient than 80% of drivers."
if (efficiency > 10) return "Good! You're above average in fuel efficiency."
return 'Room for improvement. Check our tips to save more fuel.'
}
</script>
<style scoped>
.fun-stats {
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="achievement-showcase">
<!-- Mode indicator -->
<div class="mode-indicator mb-8 p-4 rounded-xl bg-gradient-to-r from-slate-50 to-gray-100 border border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div
class="w-10 h-10 rounded-full flex items-center justify-center mr-4"
:class="isPrivateGarage ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'"
>
{{ isPrivateGarage ? '🏆' : '🏅' }}
</div>
<div>
<h3 class="font-bold text-lg">
{{ isPrivateGarage ? 'Private Garage Trophy Showcase' : 'Corporate Fleet Badge Board' }}
</h3>
<p class="text-sm text-gray-600">
{{ isPrivateGarage
? 'Playful trophies for personal achievements'
: 'Professional badges for fleet optimization'
}}
</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-500">
Current mode:
<span class="font-semibold" :class="isPrivateGarage ? 'text-amber-700' : 'text-emerald-700'">
{{ isPrivateGarage ? 'Private Garage' : 'Corporate Fleet' }}
</span>
</div>
<button
@click="toggleMode"
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
:class="isPrivateGarage
? 'border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100'
: 'border-emerald-300 text-emerald-700 bg-emerald-50 hover:bg-emerald-100'"
>
Switch to {{ isPrivateGarage ? 'Corporate' : 'Private' }}
</button>
</div>
</div>
</div>
<!-- Dynamic component rendering -->
<div class="component-container">
<TrophyCabinet v-if="isPrivateGarage" />
<BadgeBoard v-else />
</div>
<!-- Gamification stats -->
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
<div class="flex items-center">
<div class="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xl mr-4">
📈
</div>
<div>
<div class="text-2xl font-bold text-gray-900">{{ earnedCount }}</div>
<div class="text-sm text-gray-600">Achievements Earned</div>
</div>
</div>
</div>
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
<div class="flex items-center">
<div class="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 text-xl mr-4">
🎯
</div>
<div>
<div class="text-2xl font-bold text-gray-900">{{ progressPercentage }}%</div>
<div class="text-sm text-gray-600">Overall Progress</div>
</div>
</div>
</div>
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
<div class="flex items-center">
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center text-green-600 text-xl mr-4">
</div>
<div>
<div class="text-2xl font-bold text-gray-900">{{ nextAchievement }}</div>
<div class="text-sm text-gray-600">Next Achievement</div>
</div>
</div>
</div>
</div>
<!-- Help text -->
<div class="mt-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-start">
<div class="text-gray-500 mr-3">💡</div>
<div class="text-sm text-gray-600">
<span class="font-semibold">How to earn more:</span>
{{ isPrivateGarage
? 'Add vehicles, log expenses, complete daily quizzes, and find services to unlock trophies.'
: 'Optimize fleet efficiency, reduce costs, manage multiple vehicles, and maintain service records to earn badges.'
}}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useGamificationStore } from '@/stores/gamificationStore'
import { storeToRefs } from 'pinia'
import TrophyCabinet from './TrophyCabinet.vue'
import BadgeBoard from './BadgeBoard.vue'
const appModeStore = useAppModeStore()
const gamificationStore = useGamificationStore()
const { isPrivateGarage, isCorporateFleet, toggleMode } = appModeStore
const { earnedCount, progressPercentage, lockedAchievements } = storeToRefs(gamificationStore)
const nextAchievement = computed(() => {
if (lockedAchievements.value.length > 0) {
return lockedAchievements.value[0].title
}
return 'All earned!'
})
</script>
<style scoped>
.achievement-showcase {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="badge-board">
<div class="mb-8">
<h2 class="text-2xl font-bold text-slate-800 mb-2">🏅 Efficiency Badges</h2>
<p class="text-gray-600">Professional recognition for fleet optimization and cost management.</p>
<div class="mt-6 flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full bg-emerald-500 mr-2"></div>
<span class="text-sm text-gray-700">Earned: {{ earnedCount }}</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 rounded-full bg-gray-300 mr-2"></div>
<span class="text-sm text-gray-700">Available: {{ totalAchievements - earnedCount }}</span>
</div>
</div>
<div class="text-right">
<div class="text-sm text-gray-500">Fleet Score</div>
<div class="text-2xl font-bold text-slate-800">{{ fleetScore }}/100</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="achievement in achievements"
:key="achievement.id"
class="badge-card p-6 rounded-xl border transition-all duration-300"
:class="[
achievement.isEarned
? 'border-emerald-200 bg-white shadow-md hover:shadow-lg'
: 'border-gray-200 bg-gray-50'
]"
>
<!-- Badge header -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-center">
<div
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl"
:class="achievement.isEarned ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-400'"
>
{{ achievement.icon }}
</div>
<div class="ml-4">
<h3 class="font-bold text-lg" :class="achievement.isEarned ? 'text-slate-900' : 'text-gray-500'">
{{ achievement.title }}
</h3>
<div class="text-xs font-medium px-2 py-1 rounded-full inline-block mt-1"
:class="achievement.isEarned ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-500'">
{{ achievement.category.toUpperCase() }}
</div>
</div>
</div>
<!-- Status indicator -->
<div v-if="achievement.isEarned" class="text-emerald-600">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
<div v-else class="text-gray-400">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
</div>
<!-- Description -->
<p class="text-gray-600 mb-5" :class="{ 'opacity-70': !achievement.isEarned }">
{{ achievement.description }}
</p>
<!-- Progress bar for unearned badges -->
<div v-if="!achievement.isEarned" class="mt-4">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>Progress</span>
<span>0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-gray-400 h-2 rounded-full" style="width: 0%"></div>
</div>
</div>
<!-- Earned details -->
<div v-if="achievement.isEarned" class="mt-4 pt-4 border-t border-gray-100">
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500">
<span class="font-medium">Awarded:</span> {{ achievement.earnedDate }}
</div>
<div class="text-sm font-semibold text-emerald-700">
+{{ badgePoints(achievement.category) }} pts
</div>
</div>
</div>
<!-- Action button -->
<div class="mt-6">
<button
v-if="!achievement.isEarned"
class="w-full py-2 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
disabled
>
Not Yet Achieved
</button>
<button
v-else
class="w-full py-2 text-sm font-medium rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition-colors"
>
View Details
</button>
</div>
</div>
</div>
<!-- Summary stats -->
<div class="mt-10 p-6 bg-slate-50 rounded-xl border border-slate-200">
<h3 class="font-bold text-lg text-slate-800 mb-4">Fleet Performance Summary</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-slate-800">{{ earnedCount }}</div>
<div class="text-sm text-gray-600">Badges Earned</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-slate-800">{{ fleetScore }}</div>
<div class="text-sm text-gray-600">Fleet Score</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-slate-800">{{ efficiencyBadgesCount }}</div>
<div class="text-sm text-gray-600">Efficiency Badges</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-slate-800">{{ corporateBadgesCount }}</div>
<div class="text-sm text-gray-600">Corporate Badges</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useGamificationStore } from '@/stores/gamificationStore'
import { storeToRefs } from 'pinia'
const gamificationStore = useGamificationStore()
const {
achievements,
earnedCount,
totalAchievements
} = storeToRefs(gamificationStore)
// Computed
const fleetScore = computed(() => {
const base = earnedCount.value * 12
return Math.min(base, 100)
})
const efficiencyBadgesCount = computed(() => {
return achievements.value.filter(a =>
a.category === 'efficiency' && a.isEarned
).length
})
const corporateBadgesCount = computed(() => {
return achievements.value.filter(a =>
a.category === 'corporate' && a.isEarned
).length
})
const badgePoints = (category) => {
const points = {
efficiency: 25,
corporate: 30,
finance: 20,
service: 15,
onboarding: 10,
knowledge: 15,
consistency: 10,
social: 5
}
return points[category] || 10
}
</script>
<style scoped>
.badge-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.badge-card:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="trophy-cabinet">
<div class="mb-6">
<h2 class="text-2xl font-bold text-amber-800 mb-2">🏆 Trophy Cabinet</h2>
<p class="text-gray-600">Your earned achievements shine here! Collect more to fill your shelf.</p>
<div class="mt-4 flex items-center">
<div class="w-full bg-gray-200 rounded-full h-3">
<div
class="bg-gradient-to-r from-amber-400 to-amber-600 h-3 rounded-full transition-all duration-500"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
<span class="ml-4 text-sm font-semibold text-amber-700">{{ earnedCount }}/{{ totalAchievements }} ({{ progressPercentage }}%)</span>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div
v-for="achievement in achievements"
:key="achievement.id"
class="relative group"
>
<div
class="trophy-card p-5 rounded-2xl border-2 transition-all duration-300 transform"
:class="[
achievement.isEarned
? 'border-amber-300 bg-gradient-to-br from-amber-50 to-amber-100 shadow-lg hover:shadow-2xl hover:scale-105'
: 'border-gray-300 bg-gray-100 opacity-60 grayscale'
]"
>
<!-- Trophy Icon -->
<div class="text-5xl mb-4 text-center">
{{ achievement.icon }}
</div>
<!-- Lock overlay for unearned -->
<div
v-if="!achievement.isEarned"
class="absolute inset-0 bg-gray-800 bg-opacity-70 rounded-2xl flex items-center justify-center"
>
<div class="text-white text-center">
<div class="text-3xl mb-2">🔒</div>
<div class="text-sm font-semibold">Locked</div>
</div>
</div>
<!-- Content -->
<h3 class="text-lg font-bold mb-2" :class="achievement.isEarned ? 'text-gray-900' : 'text-gray-500'">
{{ achievement.title }}
</h3>
<p class="text-sm mb-3" :class="achievement.isEarned ? 'text-gray-700' : 'text-gray-400'">
{{ achievement.description }}
</p>
<!-- Category badge -->
<div class="inline-block px-3 py-1 text-xs rounded-full"
:class="achievement.isEarned ? 'bg-amber-200 text-amber-800' : 'bg-gray-300 text-gray-500'">
{{ achievement.category }}
</div>
<!-- Earned date -->
<div v-if="achievement.isEarned" class="mt-4 pt-3 border-t border-amber-200">
<div class="text-xs text-amber-600 font-medium">
🎉 Earned on {{ achievement.earnedDate }}
</div>
</div>
</div>
<!-- Glow effect for earned trophies on hover -->
<div
v-if="achievement.isEarned"
class="absolute -inset-1 bg-gradient-to-r from-blue-400 to-blue-600 rounded-2xl blur opacity-0 group-hover:opacity-30 transition-opacity duration-300 -z-10"
></div>
</div>
</div>
<!-- Empty state message -->
<div v-if="earnedCount === 0" class="text-center py-12">
<div class="text-6xl mb-4">📭</div>
<h3 class="text-xl font-semibold text-gray-700 mb-2">No trophies yet!</h3>
<p class="text-gray-500">Start using the app to earn your first achievements.</p>
</div>
</div>
</template>
<script setup>
import { useGamificationStore } from '@/stores/gamificationStore'
import { storeToRefs } from 'pinia'
const gamificationStore = useGamificationStore()
const {
achievements,
earnedCount,
totalAchievements,
progressPercentage
} = storeToRefs(gamificationStore)
</script>
<style scoped>
.trophy-card {
position: relative;
z-index: 1;
min-height: 220px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,309 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
vehicles: {
type: Array,
required: true,
default: () => []
}
})
// Enhanced status colors for corporate look
const statusColors = {
'OK': 'bg-emerald-50 text-emerald-700 border border-emerald-200',
'Service Due': 'bg-blue-50 text-blue-900 border border-blue-200 animate-pulse',
'Warning': 'bg-rose-50 text-rose-700 border border-rose-200',
'draft': 'bg-yellow-50 text-yellow-800 border border-yellow-300',
'verified': 'bg-green-50 text-green-800 border border-green-300',
'active': 'bg-blue-50 text-blue-800 border border-blue-300',
'pending': 'bg-gray-50 text-gray-800 border border-gray-300',
'incomplete': 'bg-orange-50 text-orange-800 border border-orange-300'
}
const sortedVehicles = computed(() => {
return [...props.vehicles].sort((a, b) => b.monthlyExpense - a.monthlyExpense)
})
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(amount)
}
const formatMileage = (mileage) => {
return new Intl.NumberFormat('en-US').format(mileage)
}
// Country flag mapping
const getCountryFlag = (make) => {
const makeLower = make.toLowerCase()
if (makeLower.includes('bmw') || makeLower.includes('mercedes') || makeLower.includes('audi') || makeLower.includes('volkswagen') || makeLower.includes('porsche')) {
return 'https://flagcdn.com/w40/de.png'
} else if (makeLower.includes('tesla') || makeLower.includes('ford') || makeLower.includes('chevrolet') || makeLower.includes('dodge')) {
return 'https://flagcdn.com/w40/us.png'
} else if (makeLower.includes('toyota') || makeLower.includes('honda') || makeLower.includes('nissan') || makeLower.includes('mazda')) {
return 'https://flagcdn.com/w40/jp.png'
} else if (makeLower.includes('ferrari') || makeLower.includes('lamborghini') || makeLower.includes('fiat') || makeLower.includes('alfa romeo')) {
return 'https://flagcdn.com/w40/it.png'
} else if (makeLower.includes('volvo') || makeLower.includes('saab')) {
return 'https://flagcdn.com/w40/se.png'
} else if (makeLower.includes('renault') || makeLower.includes('peugeot') || makeLower.includes('citroen')) {
return 'https://flagcdn.com/w40/fr.png'
} else if (makeLower.includes('skoda') || makeLower.includes('seat')) {
return 'https://flagcdn.com/w40/cz.png'
} else {
return 'https://flagcdn.com/w40/eu.png'
}
}
</script>
<template>
<div class="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl border border-gray-300/50 overflow-hidden">
<!-- Corporate Glass Header -->
<div class="px-8 py-5 border-b border-gray-300/30 bg-gradient-to-r from-slate-900/90 to-slate-800/90 backdrop-blur-md">
<div class="flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold text-white tracking-tight">Corporate Fleet Management</h2>
<p class="text-sm text-slate-300 mt-1">Enterprise-grade vehicle oversight with real-time analytics</p>
</div>
<div class="text-right">
<div class="text-3xl font-bold text-white">{{ formatCurrency(vehicles.reduce((sum, v) => sum + v.monthlyExpense, 0)) }}</div>
<div class="text-sm text-slate-300">Total monthly fleet cost {{ vehicles.length }} assets</div>
</div>
</div>
</div>
<!-- Table Container -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-300/30">
<thead class="bg-gradient-to-r from-slate-100 to-slate-200/80">
<tr>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2">🚗</span> Vehicle
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2">🏷</span> License Plate
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2">📅</span> Year
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2">📊</span> Mileage
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2"></span> Fuel Type
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2">🔧</span> Data Status
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2">💰</span> Monthly Cost
</div>
</th>
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
<div class="flex items-center">
<span class="mr-2"></span> Actions
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-300/20">
<tr
v-for="(vehicle, index) in sortedVehicles"
:key="vehicle.id"
:class="[
'transition-all duration-200 hover:bg-slate-100/80',
index % 2 === 0 ? 'bg-white' : 'bg-slate-50/70'
]"
>
<td class="px-8 py-5 whitespace-nowrap">
<div class="flex items-center">
<div class="h-12 w-12 flex-shrink-0 bg-gradient-to-br from-slate-200 to-slate-300 rounded-xl overflow-hidden mr-4 shadow-sm border border-slate-300/50">
<img
:src="vehicle.imageUrl"
:alt="`${vehicle.make} ${vehicle.model}`"
class="h-full w-full object-cover"
/>
</div>
<div>
<div class="font-bold text-slate-900 text-lg">{{ vehicle.make }} {{ vehicle.model }}</div>
<div class="text-sm text-slate-600 mt-1">ID: {{ vehicle.id }} Asset</div>
</div>
</div>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<div class="flex items-center space-x-3">
<img
:src="getCountryFlag(vehicle.make)"
:alt="`${vehicle.make} origin flag`"
class="w-6 h-4 rounded-sm shadow-md border border-slate-300"
/>
<div>
<div class="font-mono font-bold text-slate-900 text-lg tracking-wider">{{ vehicle.licensePlate }}</div>
<div class="text-xs text-slate-500 mt-1">Registered</div>
</div>
</div>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<div class="text-center">
<div class="text-2xl font-bold text-slate-900">{{ vehicle.year }}</div>
<div class="text-xs text-slate-500 mt-1">Model Year</div>
</div>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<div class="text-center">
<div class="text-2xl font-bold text-slate-900">{{ formatMileage(vehicle.mileage) }}</div>
<div class="text-xs text-slate-500 mt-1">km</div>
</div>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<span :class="[
'px-4 py-2 rounded-full text-sm font-semibold shadow-sm',
vehicle.fuelType === 'Electric' ? 'bg-emerald-100 text-emerald-800 border border-emerald-300' :
vehicle.fuelType === 'Diesel' ? 'bg-blue-100 text-blue-800 border border-blue-300' :
'bg-amber-100 text-amber-800 border border-amber-300'
]">
{{ vehicle.fuelType }}
</span>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<div class="space-y-2">
<!-- Data Status Badge -->
<div class="flex items-center">
<span
:class="['px-3 py-1.5 rounded-full text-xs font-semibold flex items-center', statusColors[vehicle.data_status || vehicle.status] || 'bg-slate-100 text-slate-800 border border-slate-300']"
>
<span
class="w-2 h-2 rounded-full mr-2"
:class="{
'bg-emerald-500': (vehicle.data_status || vehicle.status) === 'OK' || (vehicle.data_status || vehicle.status) === 'verified',
'bg-blue-500': (vehicle.data_status || vehicle.status) === 'Service Due' || (vehicle.data_status || vehicle.status) === 'active',
'bg-rose-500': (vehicle.data_status || vehicle.status) === 'Warning',
'bg-yellow-500': (vehicle.data_status || vehicle.status) === 'draft',
'bg-gray-500': (vehicle.data_status || vehicle.status) === 'pending',
'bg-orange-500': (vehicle.data_status || vehicle.status) === 'incomplete'
}"
></span>
{{ vehicle.data_status || vehicle.status }}
</span>
</div>
<!-- Profile Completion Mini Progress Bar -->
<div v-if="vehicle.profile_completion_percentage !== undefined" class="flex items-center space-x-2">
<div class="w-16 bg-gray-200 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="{
'bg-yellow-500': (vehicle.profile_completion_percentage || 0) < 50,
'bg-blue-500': (vehicle.profile_completion_percentage || 0) >= 50 && (vehicle.profile_completion_percentage || 0) < 80,
'bg-green-500': (vehicle.profile_completion_percentage || 0) >= 80
}"
:style="{ width: (vehicle.profile_completion_percentage || 0) + '%' }"
></div>
</div>
<span class="text-xs text-gray-600 font-medium">{{ vehicle.profile_completion_percentage || 0 }}%</span>
</div>
</div>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<div class="text-center">
<div class="text-2xl font-bold text-slate-900">{{ formatCurrency(vehicle.monthlyExpense) }}</div>
<div class="text-xs text-slate-500 mt-1">per month</div>
</div>
</td>
<td class="px-8 py-5 whitespace-nowrap">
<div class="flex space-x-2">
<button class="px-4 py-2.5 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
View Details
</button>
<button class="px-3 py-2.5 border border-slate-300 hover:bg-slate-100 rounded-xl text-slate-700 transition-all duration-200 active:scale-95 shadow-sm hover:shadow">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Corporate Footer -->
<div class="px-8 py-5 border-t border-gray-300/30 bg-gradient-to-r from-slate-100 to-slate-200/80">
<div class="flex justify-between items-center">
<div class="text-sm text-slate-700">
<span class="font-semibold">Showing {{ vehicles.length }} of {{ vehicles.length }} corporate assets</span>
<span class="mx-2"></span>
<span>Last updated: {{ new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}</span>
</div>
<div class="flex space-x-3">
<button class="px-5 py-2.5 border border-slate-300 hover:bg-white text-slate-700 font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-sm hover:shadow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export CSV
</button>
<button class="px-5 py-2.5 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Vehicle
</button>
<button class="px-5 py-2.5 bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-800 hover:to-slate-900 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Analytics
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Custom table styles */
table {
border-spacing: 0;
}
/* Smooth row transitions */
tr {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Custom scrollbar for table */
.overflow-x-auto::-webkit-scrollbar {
height: 8px;
}
.overflow-x-auto::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.overflow-x-auto::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup>
import { useThemeStore } from '@/stores/themeStore'
defineProps({
vehicle: {
type: Object,
required: true
}
})
const themeStore = useThemeStore()
const themeClasses = themeStore.themeClasses
const statusColors = {
'OK': 'bg-green-100 text-green-800',
'Service Due': 'bg-blue-100 text-blue-900',
'Warning': 'bg-orange-100 text-orange-800',
'draft': 'bg-yellow-100 text-yellow-800',
'verified': 'bg-green-100 text-green-800',
'active': 'bg-blue-100 text-blue-800',
'pending': 'bg-gray-100 text-gray-800',
'incomplete': 'bg-orange-100 text-orange-800'
}
const brandLogoUrl = (make) => {
const cleanMake = (make || '').toLowerCase().replace(/\s+/g, '')
// Use simpleicons CDN
return `https://cdn.simpleicons.org/${cleanMake}`
}
// Country flag mapping
const getCountryFlag = (make) => {
const makeLower = (make || '').toLowerCase()
if (makeLower.includes('bmw') || makeLower.includes('mercedes') || makeLower.includes('audi') || makeLower.includes('volkswagen') || makeLower.includes('porsche')) {
return 'https://flagcdn.com/w40/de.png'
} else if (makeLower.includes('tesla') || makeLower.includes('ford') || makeLower.includes('chevrolet') || makeLower.includes('dodge')) {
return 'https://flagcdn.com/w40/us.png'
} else if (makeLower.includes('toyota') || makeLower.includes('honda') || makeLower.includes('nissan') || makeLower.includes('mazda')) {
return 'https://flagcdn.com/w40/jp.png'
} else if (makeLower.includes('ferrari') || makeLower.includes('lamborghini') || makeLower.includes('fiat') || makeLower.includes('alfa romeo')) {
return 'https://flagcdn.com/w40/it.png'
} else if (makeLower.includes('volvo') || makeLower.includes('saab')) {
return 'https://flagcdn.com/w40/se.png'
} else if (makeLower.includes('renault') || makeLower.includes('peugeot') || makeLower.includes('citroen')) {
return 'https://flagcdn.com/w40/fr.png'
} else if (makeLower.includes('skoda') || makeLower.includes('seat')) {
return 'https://flagcdn.com/w40/cz.png'
} else {
return 'https://flagcdn.com/w40/eu.png'
}
}
</script>
<template>
<div :class="['rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-500 border', themeClasses.card]">
<!-- Vehicle Image -->
<div class="h-48 bg-gray-200 relative overflow-hidden">
<img
:src="vehicle.imageUrl"
:alt="`${vehicle.make} ${vehicle.model}`"
class="w-full h-full object-cover"
/>
<!-- Brand Logo -->
<div class="absolute top-3 left-3 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-md">
<img
:src="brandLogoUrl(vehicle.make)"
:alt="vehicle.make"
class="w-8 h-8"
@error="(e) => e.target.style.display = 'none'"
/>
</div>
<div class="absolute top-3 right-3">
<span
:class="['px-3 py-1 rounded-full text-xs font-semibold', statusColors[vehicle.data_status || vehicle.status] || 'bg-gray-100 text-gray-800']"
>
{{ vehicle.data_status || vehicle.status }}
</span>
</div>
</div>
<!-- Vehicle Details -->
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-xl font-bold text-gray-900">{{ vehicle.make }} {{ vehicle.model }}</h3>
<p class="text-gray-600">{{ vehicle.year }} {{ vehicle.fuelType }}</p>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-blue-700">{{ vehicle.monthlyExpense }}</div>
<div class="text-sm text-gray-500">/month</div>
</div>
</div>
<!-- License Plate with Country Flag -->
<div class="mb-4">
<div class="inline-flex items-center bg-gray-100 px-4 py-2 rounded-lg space-x-3">
<img
:src="getCountryFlag(vehicle.make)"
:alt="`${vehicle.make} origin flag`"
class="w-6 h-4 rounded-sm shadow-sm"
/>
<span class="text-gray-700 font-mono font-bold text-lg">{{ vehicle.licensePlate }}</span>
</div>
</div>
<!-- Profile Completion Progress Bar (shown for all vehicles with <100% completion) -->
<div v-if="(vehicle.profile_completion_percentage || 0) < 100" class="mb-4">
<div class="flex items-center space-x-2">
<span
v-if="vehicle.data_status === 'draft'"
class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800"
>
DRAFT
</span>
<div class="flex-1">
<div class="text-xs text-gray-600 mb-1">Profile: {{ vehicle.profile_completion_percentage || 0 }}% Complete</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-500"
:class="{
'bg-yellow-500': (vehicle.profile_completion_percentage || 0) < 50,
'bg-blue-500': (vehicle.profile_completion_percentage || 0) >= 50 && (vehicle.profile_completion_percentage || 0) < 80,
'bg-green-500': (vehicle.profile_completion_percentage || 0) >= 80
}"
:style="{ width: (vehicle.profile_completion_percentage || 0) + '%' }"
></div>
</div>
</div>
</div>
<p v-if="vehicle.data_status === 'draft'" class="text-xs text-gray-500 mt-1">Edit vehicle to provide VIN or Catalog ID</p>
<p v-else class="text-xs text-gray-500 mt-1">Complete your vehicle profile for better service recommendations</p>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="text-center">
<div class="text-2xl font-bold text-gray-900">{{ ((vehicle.mileage || 0) / 1000).toFixed(1) }}k</div>
<div class="text-sm text-gray-600">km</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-gray-900">{{ vehicle.fuelType?.charAt(0) || 'U' }}</div>
<div class="text-sm text-gray-600">Fuel</div>
</div>
</div>
<!-- Actions -->
<div class="flex space-x-3">
<button class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors duration-200">
View Details
</button>
<button class="px-4 py-3 border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* Custom scrollbar for future use */
</style>

View File

@@ -0,0 +1,271 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAppModeStore } from '@/stores/appModeStore'
import { useGarageStore } from '@/stores/garageStore'
import { useAuthStore } from '@/stores/authStore'
import VehicleCard from './VehicleCard.vue'
import FleetTable from './FleetTable.vue'
import api from '@/services/api'
const router = useRouter()
const appModeStore = useAppModeStore()
const garageStore = useGarageStore()
const authStore = useAuthStore()
// Animation state
const isMounted = ref(false)
const organizations = ref([])
const activeOrganizationName = ref('')
const loadingOrganizations = ref(false)
// Fetch vehicles on component mount (simulated)
onMounted(() => {
garageStore.fetchVehicles()
// Trigger animation after mount
setTimeout(() => {
isMounted.value = true
}, 100)
// Fetch organizations if in fleet mode
if (appModeStore.isCorporateFleet) {
fetchOrganizations()
}
})
// Watch for mode changes
watch(() => appModeStore.mode, (newMode) => {
if (newMode === 'fleet') {
fetchOrganizations()
} else {
activeOrganizationName.value = ''
}
})
// Fetch user's organizations
const fetchOrganizations = async () => {
if (!authStore.token) return
try {
loadingOrganizations.value = true
const response = await api.get('/organizations/my')
organizations.value = response.data || []
// Find active organization name
const activeOrgId = authStore.activeOrgId || authStore.userProfile?.active_organization_id
if (activeOrgId && organizations.value.length > 0) {
// Try to find organization by ID
// Note: The current endpoint returns limited data, we may need to enhance it
const org = organizations.value.find(o => o.organization_id === parseInt(activeOrgId))
if (org) {
activeOrganizationName.value = org.display_name || org.name || org.full_name || `Organization #${activeOrgId}`
} else {
activeOrganizationName.value = 'Corporate Fleet'
}
} else {
activeOrganizationName.value = 'Corporate Fleet'
}
} catch (error) {
console.error('Failed to fetch organizations:', error)
activeOrganizationName.value = 'Corporate Fleet'
} finally {
loadingOrganizations.value = false
}
}
const stats = computed(() => ({
totalVehicles: garageStore.totalVehicles,
totalMonthlyExpense: garageStore.totalMonthlyExpense,
vehiclesNeedingService: garageStore.vehiclesNeedingService
}))
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(amount)
}
// Computed for header title
const headerTitle = computed(() => {
if (appModeStore.isPrivateGarage) {
return 'Saját Garázs'
} else {
if (activeOrganizationName.value && activeOrganizationName.value !== 'Corporate Fleet') {
return `Céges Flotta: ${activeOrganizationName.value}`
}
return 'Céges Flotta'
}
})
// Computed for header description
const headerDescription = computed(() => {
if (appModeStore.isPrivateGarage) {
return 'Your personal vehicle collection and maintenance tracker'
} else {
if (activeOrganizationName.value && activeOrganizationName.value !== 'Corporate Fleet') {
return `Company-wide vehicle management and cost analytics for ${activeOrganizationName.value}`
}
return 'Company-wide vehicle management and cost analytics'
}
})
</script>
<template>
<div class="space-y-8">
<!-- Header with Stats -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-6 border border-blue-100">
<div class="flex justify-between items-center mb-6">
<div>
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold text-gray-900">
{{ headerTitle }}
</h1>
<span
v-if="appModeStore.isPrivateGarage"
class="px-3 py-1 bg-gradient-to-r from-green-500 to-emerald-600 text-white text-xs font-semibold rounded-full shadow-sm"
>
B2C
</span>
<span
v-else
class="px-3 py-1 bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm"
>
B2B
</span>
</div>
<p class="text-gray-600 mt-2">
{{ headerDescription }}
</p>
</div>
<div class="flex items-center space-x-4">
<button
@click="appModeStore.toggleMode"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-50 transition-all duration-300 flex items-center active:scale-95"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Switch to {{ appModeStore.isPrivateGarage ? 'Corporate' : 'Private' }} View
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="(stat, index) in [
{ label: 'Total Vehicles', value: stats.totalVehicles, icon: 'check', color: 'blue' },
{ label: 'Monthly Cost', value: formatCurrency(stats.totalMonthlyExpense), icon: 'currency', color: 'green' },
{ label: 'Need Service', value: stats.vehiclesNeedingService, icon: 'warning', color: 'orange' }
]"
:key="stat.label"
class="bg-white rounded-xl p-5 shadow-sm border border-gray-200 transition-all duration-500 hover:shadow-md hover:-translate-y-1"
:style="{
opacity: isMounted ? 1 : 0,
transform: isMounted ? 'translateY(0)' : 'translateY(20px)',
transitionDelay: `${index * 100}ms`
}"
>
<div class="flex items-center">
<div :class="[`p-3 bg-${stat.color}-100 rounded-lg mr-4`]">
<svg xmlns="http://www.w3.org/2000/svg" :class="[`h-6 w-6 text-${stat.color}-600`]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path v-if="stat.icon === 'check'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path v-if="stat.icon === 'currency'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path v-if="stat.icon === 'warning'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.998-.833-2.732 0L4.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<div class="text-2xl font-bold text-gray-900">{{ stat.value }}</div>
<div class="text-gray-600">{{ stat.label }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dual-UI Content -->
<div v-if="appModeStore.isPrivateGarage">
<!-- Private Garage: Card Grid with TransitionGroup -->
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">My Vehicles</h2>
<div class="text-gray-600">
{{ garageStore.vehicles.length }} vehicles in your garage
</div>
</div>
<TransitionGroup
name="stagger-card"
tag="div"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-6"
>
<VehicleCard
v-for="(vehicle, index) in garageStore.vehicles"
:key="vehicle.id"
:vehicle="vehicle"
:style="{
opacity: isMounted ? 1 : 0,
transform: isMounted ? 'translateY(0) scale(1)' : 'translateY(30px) scale(0.95)',
transitionDelay: `${index * 150}ms`
}"
class="transition-all duration-700 ease-out"
/>
</TransitionGroup>
</div>
</div>
<div v-else>
<!-- Corporate Fleet: Table View -->
<FleetTable :vehicles="garageStore.vehicles" />
</div>
<!-- Empty State (if no vehicles) -->
<div v-if="garageStore.vehicles.length === 0" class="text-center py-12">
<div class="text-6xl text-gray-300 mb-4">🚗</div>
<h3 class="text-xl font-bold text-gray-500 mb-2">No vehicles yet</h3>
<p class="text-gray-600 mb-6">Add your first vehicle to get started</p>
<button
@click="router.push('/vehicles/add')"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-300 hover:scale-105 active:scale-95"
>
Add First Vehicle
</button>
</div>
</div>
</template>
<style scoped>
/* Staggered card animations */
.stagger-card-move {
transition: transform 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stagger-card-enter-active,
.stagger-card-leave-active {
transition: all 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stagger-card-enter-from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
.stagger-card-leave-to {
opacity: 0;
transform: translateY(-30px) scale(0.95);
}
.stagger-card-leave-active {
position: absolute;
}
/* Smooth hover effects */
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
</style>

View File

@@ -0,0 +1,33 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { markRaw } from 'vue'
import router from './router'
import './style.css'
import App from './App.vue'
const app = createApp(App)
// Global error handler
app.config.errorHandler = (err, instance, info) => {
console.error('Global Vue error caught:', err)
console.error('Error info:', info)
// Optionally show a user-friendly error message
// You could integrate with a notification store here
}
// Global promise rejection handler (for unhandled async errors)
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason)
event.preventDefault()
})
const pinia = createPinia()
// Inject router into all Pinia stores
pinia.use(({ store }) => {
store.router = markRaw(router)
})
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,151 @@
import { createRouter, createWebHistory } from 'vue-router';
// Nézetek importálása
import Dashboard from '../views/Dashboard.vue';
import Expenses from '../views/Expenses.vue';
import AddExpense from '../views/AddExpense.vue';
import Login from '../views/Login.vue';
import Register from '../views/Register.vue';
import ForgotPassword from '../views/ForgotPassword.vue';
import ResetPassword from '../views/ResetPassword.vue';
import AddVehicle from '../views/AddVehicle.vue';
import AdminStats from '../views/admin/AdminStats.vue';
import ProfileSelect from '../views/ProfileSelect.vue';
import Debug from '../views/Debug.vue';
const routes = [
// Védett útvonalak
{ path: '/', name: 'Dashboard', component: Dashboard, meta: { requiresAuth: true } },
{ path: '/expenses', name: 'Expenses', component: Expenses, meta: { requiresAuth: true } },
{ path: '/expenses/add', name: 'AddExpense', component: AddExpense, meta: { requiresAuth: true } },
{ path: '/vehicles/add', name: 'AddVehicle', component: AddVehicle, meta: { requiresAuth: true } },
// Profile selection (public but requires auth)
{ path: '/profile-select', name: 'ProfileSelect', component: ProfileSelect, meta: { requiresAuth: true } },
// ADMIN útvonal
{
path: '/admin',
name: 'Admin',
component: AdminStats,
meta: { requiresAuth: true, requiresAdmin: true }
},
// Nyilvános útvonalak
{ path: '/login', name: 'Login', component: Login },
{ path: '/register', name: 'Register', component: Register },
{ path: '/forgot-password', name: 'ForgotPassword', component: ForgotPassword },
{ path: '/reset-password', name: 'ResetPassword', component: ResetPassword },
// DEBUG útvonal (nyilvános, nincs auth check)
{ path: '/debug', name: 'Debug', component: Debug },
];
const router = createRouter({
history: createWebHistory(),
routes
});
// Helper function to check if UI mode is selected
function hasUIModeSelected() {
if (typeof window === 'undefined') return false;
const saved = localStorage.getItem('ui_mode');
// Accept both UI values (private_garage, corporate_fleet) and backend values (personal, fleet)
return saved === 'private_garage' || saved === 'corporate_fleet' || saved === 'personal' || saved === 'fleet';
}
// A "SOROMPÓ" (Auth Guard) LOGIKA
router.beforeEach((to, from, next) => {
console.group('🚀 Router Navigation Guard - Flight Recorder');
console.log(`📊 Navigation: ${from.path}${to.path}`);
console.log(`📍 To Route:`, to);
const token = localStorage.getItem('token');
const isAdmin = localStorage.getItem('is_admin') === 'true';
const uiMode = localStorage.getItem('ui_mode');
// Import stores to get real-time state
let isAuthenticated = false;
let appMode = null;
try {
// Try to get auth store state if available
const authStore = window.__pinia?.state.value?.auth;
if (authStore) {
isAuthenticated = !!authStore.token;
console.log('🔐 Auth Store State:', authStore);
}
} catch (e) {
console.warn('⚠️ Could not access auth store:', e.message);
}
try {
// Try to get app mode store state if available
const appModeStore = window.__pinia?.state.value?.appMode;
if (appModeStore) {
appMode = appModeStore.mode;
console.log('🎛️ App Mode Store State:', appModeStore);
}
} catch (e) {
console.warn('⚠️ Could not access app mode store:', e.message);
}
console.log('📋 Authentication Status:');
console.log(' • Token in localStorage:', token ? `YES (${token.substring(0, 20)}...)` : 'NO');
console.log(' • isAdmin flag:', isAdmin);
console.log(' • UI Mode in localStorage:', uiMode || 'NOT SET');
console.log(' • Auth Store isAuthenticated:', isAuthenticated);
console.log(' • App Mode Store mode:', appMode);
console.log(' • Route requiresAuth:', to.meta.requiresAuth || false);
console.log(' • Route requiresAdmin:', to.meta.requiresAdmin || false);
// Auth check
if (to.meta.requiresAuth && !token) {
console.warn('❌ AUTH FAILED: Route requires auth but no token found');
console.warn(` ↳ Redirecting to /login (from ${to.path})`);
console.groupEnd();
next('/login');
return;
}
// Admin check
if (to.meta.requiresAdmin && !isAdmin) {
console.warn('❌ ADMIN CHECK FAILED: Route requires admin but user is not admin');
console.warn(` ↳ Redirecting to / (from ${to.path})`);
console.groupEnd();
next('/');
return;
}
// UI mode selection logic
if (to.meta.requiresAuth && token) {
const hasMode = hasUIModeSelected();
console.log('🎯 UI Mode Check:');
console.log(' • hasUIModeSelected():', hasMode);
console.log(' • Target route name:', to.name);
// If user tries to access dashboard without mode selection, redirect to profile-select
if (to.name === 'Dashboard' && !hasMode) {
console.warn('⚠️ UI MODE MISSING: Dashboard access without mode selection');
console.warn(` ↳ Redirecting to /profile-select (from ${to.path})`);
console.groupEnd();
next('/profile-select');
return;
}
// If user tries to access profile-select but already has mode, redirect to dashboard
if (to.name === 'ProfileSelect' && hasMode) {
console.warn('⚠️ UI MODE ALREADY SET: Profile-select access with existing mode');
console.warn(` ↳ Redirecting to / (from ${to.path})`);
console.groupEnd();
next('/');
return;
}
}
console.log('✅ ALL CHECKS PASSED: Allowing navigation to', to.path);
console.groupEnd();
next();
});
export default router;

View File

@@ -0,0 +1,167 @@
import axios from 'axios'
// Create axios instance with base URL from environment variable
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
console.group('📤 Axios Request Interceptor - Flight Recorder');
console.log('📊 Request Details:');
console.log(' • URL:', config.url);
console.log(' • Method:', config.method);
console.log(' • Base URL:', config.baseURL);
// Get token from localStorage (check both 'token' and 'access_token' for compatibility)
if (typeof window !== 'undefined') {
let token = localStorage.getItem('token');
if (!token) {
token = localStorage.getItem('access_token');
if (token) {
console.log('⚠️ Using access_token (legacy) instead of token');
}
}
if (token) {
console.log('🔐 Adding Authorization header with token');
console.log(' • Token present:', token.substring(0, 20) + '...');
config.headers.Authorization = `Bearer ${token}`;
} else {
console.log('⚠️ No auth token found in localStorage');
console.log(' • token key:', localStorage.getItem('token') ? 'PRESENT' : 'MISSING');
console.log(' • access_token key:', localStorage.getItem('access_token') ? 'PRESENT' : 'MISSING');
}
} else {
console.log('🌐 Window not available (SSR)');
}
console.groupEnd();
return config;
},
(error) => {
console.error('❌ Request interceptor error:', error);
return Promise.reject(error);
}
)
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
console.group('🚨 Axios Response Interceptor - Flight Recorder');
console.log('📊 Interceptor triggered for error:', error);
if (error.response) {
console.log('📡 Response Details:');
console.log(' • Status:', error.response.status);
console.log(' • URL:', error.config?.url);
console.log(' • Method:', error.config?.method);
console.log(' • Headers:', error.config?.headers);
if (error.response.status === 401) {
console.warn('🔐 401 UNAUTHORIZED DETECTED!');
console.warn(' ↳ This will trigger logout and redirect');
// Log current auth state before clearing
const token = localStorage.getItem('token');
const accessToken = localStorage.getItem('access_token');
console.log(' ↳ Current localStorage state:');
console.log(' - token:', token ? `YES (${token.substring(0, 20)}...)` : 'NO');
console.log(' - access_token:', accessToken ? `YES (${accessToken.substring(0, 20)}...)` : 'NO');
// Handle unauthorized - clear ALL auth tokens and redirect to login
if (typeof window !== 'undefined') {
console.log(' ↳ Clearing auth tokens from localStorage...');
localStorage.removeItem('token');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('is_admin');
localStorage.removeItem('user_email');
localStorage.removeItem('user_role');
// Also try to call auth store logout if available
try {
const authStore = window.__pinia?.state.value?.auth;
if (authStore && authStore.logout) {
console.log(' ↳ Calling auth store logout()...');
// We can't call the function directly from here, but we can dispatch an event
window.dispatchEvent(new CustomEvent('force-logout'));
}
} catch (e) {
console.warn(' ↳ Could not access auth store:', e.message);
}
console.warn(' ↳ Redirecting to /login');
window.location.href = '/login';
}
} else if (error.response.status === 403) {
console.warn('🔒 403 FORBIDDEN DETECTED');
console.warn(' ↳ User lacks permissions for this resource');
} else if (error.response.status === 404) {
console.warn('🔍 404 NOT FOUND DETECTED');
console.warn(' ↳ API endpoint or resource not found');
} else if (error.response.status >= 500) {
console.error('💥 SERVER ERROR DETECTED (5xx)');
console.error(' ↳ Backend server issue');
}
} else if (error.request) {
console.error('🌐 NETWORK ERROR: Request was made but no response received');
console.error(' ↳ Possible network issue or CORS problem');
} else {
console.error('⚙️ SETUP ERROR: Error in request configuration');
console.error(' ↳', error.message);
}
console.groupEnd();
return Promise.reject(error);
}
)
export default api
// Catalog API functions
export const catalogApi = {
async getMakes(vehicleClass = null) {
const params = {}
if (vehicleClass) {
params.vehicle_class = vehicleClass
}
const response = await api.get('/api/v1/catalog/makes', { params })
return response.data
},
async getModels(make, vehicleClass = null) {
const params = { make }
if (vehicleClass) {
params.vehicle_class = vehicleClass
}
const response = await api.get('/api/v1/catalog/models', { params })
return response.data
},
async getGenerations(make, model) {
const response = await api.get('/api/v1/catalog/generations', { params: { make, model } })
return response.data
},
async getEngines(make, model, gen) {
const response = await api.get('/api/v1/catalog/engines', { params: { make, model, gen } })
return response.data
}
}
// Organization API functions
export const organizationApi = {
async getMyOrganizations() {
const response = await api.get('/organizations/my')
return response.data
},
async updateActiveOrganization(organizationId) {
const response = await api.patch('/users/me/active-organization', {
organization_id: organizationId
})
return response.data
}
}

View File

@@ -0,0 +1,202 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './authStore'
export const useAnalyticsStore = defineStore('analytics', () => {
const authStore = useAuthStore()
// Real data - initially empty, will be fetched from API
const monthlyCosts = ref([])
const fuelEfficiencyTrends = ref([])
const costPerKmTrends = ref([])
const funFacts = ref({
totalKmDriven: 0,
totalTreesSaved: 0,
totalCo2Saved: 0,
totalMoneySaved: 0,
moonTrips: computed(() => Math.round(funFacts.value.totalKmDriven / 384400)),
earthCircuits: computed(() => Math.round(funFacts.value.totalKmDriven / 40075)),
})
const businessMetrics = ref({
fleetSize: 0,
averageVehicleAge: 0,
totalMonthlyCost: 0,
averageCostPerKm: 0,
utilizationRate: 0,
downtimeHours: 0,
})
const isLoading = ref(false)
const error = ref(null)
// Getters
const totalCosts = computed(() => {
return monthlyCosts.value.reduce((sum, month) => sum + month.total, 0)
})
const averageMonthlyCost = computed(() => {
return monthlyCosts.value.length > 0 ? totalCosts.value / monthlyCosts.value.length : 0
})
const averageFuelEfficiency = computed(() => {
const sum = fuelEfficiencyTrends.value.reduce((acc, item) => acc + item.efficiency, 0)
return fuelEfficiencyTrends.value.length > 0 ? sum / fuelEfficiencyTrends.value.length : 0
})
const averageCostPerKm = computed(() => {
const sum = costPerKmTrends.value.reduce((acc, item) => acc + item.cost, 0)
return costPerKmTrends.value.length > 0 ? sum / costPerKmTrends.value.length : 0
})
// Actions
function addMonthlyCost(data) {
monthlyCosts.value.push(data)
}
function updateFuelEfficiency(month, efficiency) {
const index = fuelEfficiencyTrends.value.findIndex(item => item.month === month)
if (index !== -1) {
fuelEfficiencyTrends.value[index].efficiency = efficiency
}
}
function updateFunFacts(newFacts) {
Object.assign(funFacts.value, newFacts)
}
// Real API fetch - NO MORE MOCK DATA
async function fetchDashboardAnalytics() {
isLoading.value = true
error.value = null
try {
// Get auth token
const token = authStore.token
if (!token) {
throw new Error('Not authenticated')
}
// Call real backend API
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/analytics/dashboard`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to fetch analytics: ${response.status} ${response.statusText}`)
}
const data = await response.json()
console.log('AnalyticsStore: Fetched dashboard analytics', data)
// Transform API response to frontend format
monthlyCosts.value = data.monthly_costs || []
fuelEfficiencyTrends.value = data.fuel_efficiency_trends || []
costPerKmTrends.value = data.cost_per_km_trends || []
if (data.fun_facts) {
funFacts.value = {
totalKmDriven: data.fun_facts.total_km_driven || 0,
totalTreesSaved: data.fun_facts.total_trees_saved || 0,
totalCo2Saved: data.fun_facts.total_co2_saved || 0,
totalMoneySaved: data.fun_facts.total_money_saved || 0,
moonTrips: computed(() => Math.round((data.fun_facts.total_km_driven || 0) / 384400)),
earthCircuits: computed(() => Math.round((data.fun_facts.total_km_driven || 0) / 40075)),
}
}
if (data.business_metrics) {
businessMetrics.value = {
fleetSize: data.business_metrics.fleet_size || 0,
averageVehicleAge: data.business_metrics.average_vehicle_age || 0,
totalMonthlyCost: data.business_metrics.total_monthly_cost || 0,
averageCostPerKm: data.business_metrics.average_cost_per_km || 0,
utilizationRate: data.business_metrics.utilization_rate || 0,
downtimeHours: data.business_metrics.downtime_hours || 0,
}
}
return data
} catch (err) {
console.error('AnalyticsStore: Error fetching analytics', err)
error.value = err.message
// Keep empty data (no mock fallback)
} finally {
isLoading.value = false
}
}
// Fetch vehicle-specific analytics
async function fetchVehicleAnalytics(vehicleId) {
isLoading.value = true
error.value = null
try {
const token = authStore.token
if (!token) {
throw new Error('Not authenticated')
}
// Call vehicle summary endpoint
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/analytics/${vehicleId}/summary`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to fetch vehicle analytics: ${response.status} ${response.statusText}`)
}
const data = await response.json()
console.log('AnalyticsStore: Fetched vehicle analytics', data)
// For now, just return the data - frontend components can use it directly
return data
} catch (err) {
console.error('AnalyticsStore: Error fetching vehicle analytics', err)
error.value = err.message
throw err
} finally {
isLoading.value = false
}
}
// Fetch fleet analytics (aggregated)
async function fetchFleetAnalytics() {
// For now, use the dashboard endpoint which includes fleet metrics
return fetchDashboardAnalytics()
}
return {
// State
monthlyCosts,
fuelEfficiencyTrends,
costPerKmTrends,
funFacts,
businessMetrics,
isLoading,
error,
// Getters
totalCosts,
averageMonthlyCost,
averageFuelEfficiency,
averageCostPerKm,
// Actions
addMonthlyCost,
updateFuelEfficiency,
updateFunFacts,
fetchDashboardAnalytics,
fetchVehicleAnalytics,
fetchFleetAnalytics,
}
})

View File

@@ -0,0 +1,135 @@
import { defineStore } from 'pinia'
import { ref, computed, onMounted } from 'vue'
import api from '@/services/api'
import { useGarageStore } from './garageStore'
export const useAppModeStore = defineStore('appMode', () => {
// State
const mode = ref('personal') // backend compatible values: 'personal' or 'fleet'
const isLoading = ref(false)
// Getters
const isPrivateGarage = computed(() => mode.value === 'personal')
const isCorporateFleet = computed(() => mode.value === 'fleet')
// Actions
async function setMode(newMode) {
// Map UI values to backend compatible values
let backendMode = newMode
if (newMode === 'private_garage') {
backendMode = 'personal'
} else if (newMode === 'corporate_fleet') {
backendMode = 'fleet'
}
if (!['personal', 'fleet'].includes(backendMode)) {
console.error('Invalid mode:', newMode)
return
}
mode.value = backendMode
persistMode(backendMode)
await saveModeToBackend(backendMode)
}
function toggleMode() {
const newMode = mode.value === 'personal' ? 'fleet' : 'personal'
setMode(newMode)
}
// SSR-safe localStorage persistence
function persistMode(mode) {
if (typeof window !== 'undefined') {
localStorage.setItem('ui_mode', mode)
}
}
function getInitialMode() {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ui_mode')
// Map UI values to backend compatible values
if (saved === 'private_garage' || saved === 'personal') {
return 'personal'
} else if (saved === 'corporate_fleet' || saved === 'fleet') {
return 'fleet'
}
}
// Default mode
return 'personal'
}
// Load user preferences from backend on app startup
async function loadModeFromBackend() {
if (typeof window === 'undefined') return
try {
isLoading.value = true
const response = await api.get('/users/me')
const user = response.data
if (user.ui_mode && ['personal', 'fleet'].includes(user.ui_mode)) {
mode.value = user.ui_mode
persistMode(user.ui_mode)
}
} catch (error) {
console.warn('Failed to load UI mode from backend, using local storage', error)
} finally {
isLoading.value = false
}
}
// Save mode to backend via PATCH /users/me/preferences
async function saveModeToBackend(newMode) {
try {
await api.patch('/users/me/preferences', {
ui_mode: newMode
})
// Also update active organization based on mode
// When switching to fleet mode, we need to set an active organization
// When switching to personal mode, we should clear the active organization
if (newMode === 'fleet') {
// Try to get the user's active organization from auth store
// For now, we'll just make sure the backend knows we're in fleet mode
// The actual organization selection should happen elsewhere
console.log('Switched to fleet mode - organization selection may be needed')
} else if (newMode === 'personal') {
// Clear active organization when switching to personal mode
try {
await api.patch('/users/me/active-organization', {
organization_id: null
})
console.log('Cleared active organization for personal mode')
} catch (orgError) {
console.warn('Failed to clear active organization', orgError)
// Non-critical error, continue
}
}
// Refresh the garage store to reflect the new scope
try {
const garageStore = useGarageStore()
await garageStore.fetchVehicles()
console.log('Garage store refreshed after mode change')
} catch (refreshError) {
console.warn('Failed to refresh garage store', refreshError)
// Non-critical error, continue
}
} catch (error) {
console.error('Failed to save UI mode to backend', error)
// Optionally revert local state? For now just log.
}
}
// Initialize
mode.value = getInitialMode()
// Load from backend after store creation (call in component's onMounted)
// We expose loadModeFromBackend for components to call
return {
mode,
isLoading,
isPrivateGarage,
isCorporateFleet,
setMode,
toggleMode,
loadModeFromBackend,
}
})

View File

@@ -0,0 +1,322 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '../router'
export const useAuthStore = defineStore('auth', () => {
// State
const token = ref(localStorage.getItem('token') || '')
const isAdmin = ref(localStorage.getItem('is_admin') === 'true')
const userEmail = ref(localStorage.getItem('user_email') || '')
const userRole = ref(localStorage.getItem('user_role') || '')
const userProfile = ref(null) // Full user profile from /users/me
const activeOrgId = ref(localStorage.getItem('active_org_id') || null) // Active organization ID
// Getters
const isLoggedIn = computed(() => !!token.value)
const isTester = computed(() => userEmail.value === 'tester_pro@profibot.hu' || userRole.value === 'tester')
const displayName = computed(() => {
if (isTester.value) return 'TESTER PRO'
if (isAdmin.value) return 'ADMIN'
return 'USER'
})
// Actions
const login = async (email, password) => {
console.log('AuthStore: Real API login attempt for', email)
try {
// Prepare URL-encoded form data for OAuth2 password grant
// FastAPI's OAuth2PasswordRequestForm expects application/x-www-form-urlencoded
const params = new URLSearchParams()
params.append('username', email)
params.append('password', password)
// Call real backend API
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
const response = await fetch(`${apiBaseUrl}/auth/login`, {
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
})
if (!response.ok) {
const errorText = await response.text()
console.error('AuthStore: Login API error', response.status, errorText)
throw new Error(`Login failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
console.log('AuthStore: Login API response', data)
// Extract token and user info
const accessToken = data.access_token
const refreshToken = data.refresh_token
const tokenType = data.token_type
const isActive = data.is_active
// We need to decode the JWT token to get user role and info
// For now, we'll make a separate API call to get user info
// Or we can parse the JWT token (simple base64 decode)
let roleValue = 'user'
let adminFlag = false
try {
// Decode JWT token to get payload
const tokenParts = accessToken.split('.')
if (tokenParts.length === 3) {
const payload = JSON.parse(atob(tokenParts[1]))
roleValue = payload.role || 'user'
adminFlag = roleValue === 'admin' || roleValue === 'superadmin'
console.log('AuthStore: Decoded JWT payload', payload)
}
} catch (decodeError) {
console.warn('AuthStore: Could not decode JWT token', decodeError)
// Fallback: Make API call to get user info
// For now, we'll use a default role
}
// Save to localStorage
localStorage.setItem('token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
localStorage.setItem('is_admin', adminFlag.toString())
localStorage.setItem('user_email', email)
localStorage.setItem('user_role', roleValue)
// Update store state
token.value = accessToken
isAdmin.value = adminFlag
userEmail.value = email
userRole.value = roleValue
console.log('AuthStore: State updated, redirecting to /profile-select')
// Redirect to profile-select (as per router logic)
try {
await router.push('/profile-select')
console.log('AuthStore: Redirect successful')
} catch (error) {
console.error('AuthStore: Router redirect failed:', error)
throw error
}
return { success: true, token: accessToken, isAdmin: adminFlag, role: roleValue }
} catch (error) {
console.error('AuthStore: Login failed', error)
throw error // Re-throw the error instead of falling back to mock
}
}
const logout = () => {
// Clear localStorage
localStorage.removeItem('token')
localStorage.removeItem('is_admin')
localStorage.removeItem('user_email')
localStorage.removeItem('user_role')
localStorage.removeItem('ui_mode') // Also clear UI mode on logout
localStorage.removeItem('active_org_id') // Clear active organization ID
// Reset store state
token.value = ''
isAdmin.value = false
userEmail.value = ''
userRole.value = ''
userProfile.value = null
activeOrgId.value = null
// Redirect to login
router.push('/login')
}
const fetchUserProfile = async () => {
if (!token.value) {
throw new Error('Not authenticated')
}
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
const response = await fetch(`${apiBaseUrl}/users/me`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token.value}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user profile: ${response.status} ${response.statusText}`)
}
const data = await response.json()
console.log('AuthStore: Fetched user profile', data)
// Update user profile state
userProfile.value = data
// Also update email and role from profile if available
if (data.email && !userEmail.value) {
userEmail.value = data.email
localStorage.setItem('user_email', data.email)
}
if (data.role && !userRole.value) {
userRole.value = data.role
localStorage.setItem('user_role', data.role)
isAdmin.value = data.role === 'admin' || data.role === 'superadmin'
localStorage.setItem('is_admin', isAdmin.value.toString())
}
// Store active organization ID if available
if (data.active_organization_id !== undefined) {
activeOrgId.value = data.active_organization_id
localStorage.setItem('active_org_id', data.active_organization_id)
console.log('AuthStore: Set active organization ID:', data.active_organization_id)
} else if (data.scope_id) {
// Fallback to scope_id if active_organization_id is not provided
activeOrgId.value = data.scope_id
localStorage.setItem('active_org_id', data.scope_id)
console.log('AuthStore: Set active organization ID from scope_id:', data.scope_id)
}
return data
} catch (err) {
console.error('AuthStore: Error fetching user profile', err)
throw err
}
}
// Update active organization
const updateActiveOrganization = async (organizationId) => {
if (!token.value) {
throw new Error('Not authenticated')
}
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
const response = await fetch(`${apiBaseUrl}/users/me/active-organization`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token.value}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
organization_id: organizationId
})
})
if (!response.ok) {
throw new Error(`Failed to update active organization: ${response.status} ${response.statusText}`)
}
const data = await response.json()
console.log('AuthStore: Updated active organization', data)
// Check if response contains new JWT token (backend now returns {user: {...}, access_token: "...", token_type: "bearer"})
if (data.access_token) {
console.log('AuthStore: Received new access token from organization switch')
// Update token in localStorage and store state
localStorage.setItem('token', data.access_token)
token.value = data.access_token
// Decode new token to update role if needed
try {
const tokenParts = data.access_token.split('.')
if (tokenParts.length === 3) {
const payload = JSON.parse(atob(tokenParts[1]))
const roleValue = payload.role || 'user'
const adminFlag = roleValue === 'admin' || roleValue === 'superadmin'
localStorage.setItem('user_role', roleValue)
localStorage.setItem('is_admin', adminFlag.toString())
userRole.value = roleValue
isAdmin.value = adminFlag
console.log('AuthStore: Updated role from new token:', roleValue)
}
} catch (decodeError) {
console.warn('AuthStore: Could not decode new JWT token', decodeError)
}
}
// Update local state
activeOrgId.value = organizationId
localStorage.setItem('active_org_id', organizationId || '')
// Also update user profile to reflect changes
await fetchUserProfile()
return data
} catch (err) {
console.error('AuthStore: Error updating active organization', err)
throw err
}
}
const checkAuth = () => {
// Sync with localStorage on page load
const storedToken = localStorage.getItem('token') || ''
const storedEmail = localStorage.getItem('user_email') || ''
const storedRole = localStorage.getItem('user_role') || ''
// Try to decode JWT token to get role if not stored
let decodedRole = storedRole
let decodedIsAdmin = storedRole === 'admin' || storedRole === 'superadmin'
if (storedToken && !storedRole) {
try {
// Decode JWT token to get payload
const tokenParts = storedToken.split('.')
if (tokenParts.length === 3) {
const payload = JSON.parse(atob(tokenParts[1]))
decodedRole = payload.role || 'user'
decodedIsAdmin = decodedRole === 'admin' || decodedRole === 'superadmin'
console.log('AuthStore: Decoded JWT on checkAuth', payload)
// Update localStorage with decoded role
localStorage.setItem('user_role', decodedRole)
localStorage.setItem('is_admin', decodedIsAdmin.toString())
}
} catch (decodeError) {
console.warn('AuthStore: Could not decode JWT token on checkAuth', decodeError)
}
} else if (storedToken && storedRole) {
// Use stored role
decodedIsAdmin = storedRole === 'admin' || storedRole === 'superadmin'
}
token.value = storedToken
isAdmin.value = decodedIsAdmin
userEmail.value = storedEmail
userRole.value = decodedRole || 'user'
}
// Initialize on store creation
checkAuth()
return {
// State
token,
isAdmin,
userEmail,
userRole,
userProfile,
activeOrgId,
// Getters
isLoggedIn,
isTester,
displayName,
// Actions
login,
logout,
checkAuth,
fetchUserProfile,
updateActiveOrganization
}
})

View File

@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/services/api'
export const useExpenseStore = defineStore('expense', () => {
const isLoading = ref(false)
const error = ref(null)
async function createExpense(expenseData) {
isLoading.value = true
error.value = null
try {
const response = await api.post('/expenses/', expenseData)
return response.data
} catch (err) {
error.value = err.response?.data?.detail || err.message
// Check for DRAFT_LIMIT_REACHED error
if (err.response?.status === 403 && err.response?.data?.detail === "DRAFT_LIMIT_REACHED") {
const draftError = new Error('DRAFT_LIMIT_REACHED')
draftError.response = err.response
throw draftError
}
throw err
} finally {
isLoading.value = false
}
}
return {
isLoading,
error,
createExpense,
}
})

View File

@@ -0,0 +1,158 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/services/api'
export const useGamificationStore = defineStore('gamification', () => {
// State
const achievements = ref([])
const badges = ref([])
const userStats = ref(null)
const isLoading = ref(false)
const error = ref(null)
// Getters
const earnedAchievements = computed(() =>
achievements.value.filter(a => a.is_earned)
)
const lockedAchievements = computed(() =>
achievements.value.filter(a => !a.is_earned)
)
const totalAchievements = computed(() => achievements.value.length)
const earnedCount = computed(() => earnedAchievements.value.length)
const progressPercentage = computed(() => {
if (totalAchievements.value === 0) return 0
return Math.round((earnedCount.value / totalAchievements.value) * 100)
})
const earnedBadges = computed(() =>
badges.value.filter(b => b.is_earned)
)
// Helper function for API calls (using centralized api instance)
async function apiFetch(url, options = {}) {
try {
const response = await api.get(url, options)
return response.data
} catch (error) {
if (error.response) {
throw new Error(`API error ${error.response.status}: ${JSON.stringify(error.response.data)}`)
}
throw error
}
}
// Actions
async function fetchAchievements() {
isLoading.value = true
error.value = null
try {
const data = await apiFetch('/gamification/achievements')
achievements.value = data.achievements || []
return data
} catch (err) {
console.error('Failed to fetch achievements:', err)
error.value = err.message
// No mock fallback - let the error propagate
achievements.value = []
throw err
} finally {
isLoading.value = false
}
}
async function fetchBadges() {
isLoading.value = true
error.value = null
try {
const data = await apiFetch('/gamification/my-badges')
badges.value = data.map(badge => ({
id: badge.badge_id,
title: badge.badge_name,
description: badge.badge_description,
icon_url: badge.badge_icon_url,
is_earned: true,
earned_date: badge.earned_at,
category: 'badge'
}))
return data
} catch (err) {
console.error('Failed to fetch badges:', err)
error.value = err.message
// No mock fallback - propagate error
badges.value = []
throw err
} finally {
isLoading.value = false
}
}
async function fetchUserStats() {
isLoading.value = true
error.value = null
try {
const data = await apiFetch('/gamification/me')
userStats.value = data
return data
} catch (err) {
console.error('Failed to fetch user stats:', err)
error.value = err.message
// No mock fallback - propagate error
userStats.value = null
throw err
} finally {
isLoading.value = false
}
}
async function fetchAllGamificationData() {
await Promise.all([
fetchAchievements(),
fetchBadges(),
fetchUserStats()
])
}
async function earnAchievement(id) {
// In a real implementation, this would call an API endpoint
// For now, we'll just update local state
const achievement = achievements.value.find(a => a.id === id)
if (achievement && !achievement.is_earned) {
achievement.is_earned = true
achievement.earned_date = new Date().toISOString().split('T')[0]
}
}
function resetAchievements() {
achievements.value.forEach(a => {
a.is_earned = false
a.earned_date = null
})
}
// Initialize store with data - RE-ENABLED after token fix
fetchAllGamificationData()
return {
achievements,
badges,
userStats,
earnedAchievements,
lockedAchievements,
totalAchievements,
earnedCount,
progressPercentage,
earnedBadges,
isLoading,
error,
fetchAchievements,
fetchBadges,
fetchUserStats,
fetchAllGamificationData,
earnAchievement,
resetAchievements
}
})

View File

@@ -0,0 +1,298 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './authStore'
export const useGarageStore = defineStore('garage', () => {
const authStore = useAuthStore()
// Real vehicle data - initially empty, will be fetched from API
const vehicles = ref([])
const isLoading = ref(false)
const error = ref(null)
// Getters
const totalVehicles = computed(() => vehicles.value.length)
const totalMonthlyExpense = computed(() =>
vehicles.value.reduce((sum, vehicle) => sum + (vehicle.monthlyExpense || 0), 0)
)
const vehiclesNeedingService = computed(() =>
vehicles.value.filter(v => v.status === 'Service Due' || v.status === 'Warning').length
)
// Actions
async function addVehicle(vehicle) {
// Real API call to POST /api/v1/assets (Thick Asset endpoint)
const token = authStore.token
if (!token) {
throw new Error('Not authenticated')
}
try {
// Transform frontend vehicle data to Thick Asset API schema
// Include all required fields for thick asset creation
const payload = {
// Core identification
vin: vehicle.vin || null, // Send null for draft vehicles
license_plate: vehicle.licensePlate || 'N/A',
catalog_id: vehicle.catalogId || null,
organization_id: vehicle.organizationId || authStore.activeOrgId,
// Ownership fields (required by AssetCreate schema)
owner_org_id: vehicle.owner_org_id || null,
operator_org_id: vehicle.operator_org_id || null,
// Thick Asset fields - send even if catalog_id is provided for completeness
brand: vehicle.brand || vehicle.make || null,
model: vehicle.model || null,
vehicle_class: vehicle.vehicleClass || vehicle.class || null,
fuel_type: vehicle.fuelType || vehicle.fuel || null,
year_of_manufacture: vehicle.year || vehicle.yearOfManufacture || null,
engine_capacity: vehicle.engineCapacity || null,
power_kw: vehicle.powerKw || null,
transmission: vehicle.transmission || null,
body_type: vehicle.bodyType || null,
color: vehicle.color || null,
current_mileage: vehicle.currentMileage || vehicle.mileage || 0,
// Metadata
status: vehicle.status || 'draft', // Default to draft for 2-step creation
data_status: 'incomplete', // Will be updated by backend snapshot sync
profile_completion_percentage: 0 // Will be calculated by backend
}
// Remove null values to keep payload clean
Object.keys(payload).forEach(key => {
if (payload[key] === null || payload[key] === undefined) {
delete payload[key]
}
})
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/api/v1/assets/vehicles`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = `Failed to add vehicle: ${response.status} ${response.statusText}`
// Handle specific error cases
if (response.status === 409) {
errorMessage = 'Duplicate VIN or license plate detected. Please check your input.'
} else if (response.status === 429) {
errorMessage = 'Rate limit exceeded. Please try again later.'
} else if (response.status === 400) {
errorMessage = `Invalid input: ${errorText}`
} else if (response.status === 403) {
errorMessage = 'Permission denied. You may have reached your vehicle limit.'
}
throw new Error(errorMessage)
}
const data = await response.json()
console.log('GarageStore: Thick Asset created successfully', data)
// After successful save, fetch fresh data from server to ensure consistency
await fetchVehicles()
return data
} catch (err) {
console.error('GarageStore: Error adding vehicle', err)
throw err
}
}
function removeVehicle(id) {
// In a real app, this would be an API call to DELETE /assets/{id}
vehicles.value = vehicles.value.filter(v => v.id !== id)
}
function updateVehicle(id, updates) {
// In a real app, this would be an API call to PATCH /assets/{id}
const index = vehicles.value.findIndex(v => v.id === id)
if (index !== -1) {
vehicles.value[index] = { ...vehicles.value[index], ...updates }
}
}
function getVehicleById(id) {
return vehicles.value.find(v => v.id === id)
}
// Real API fetch - NO MORE MOCK DATA
async function fetchVehicles() {
isLoading.value = true
error.value = null
try {
// Get auth token
const token = authStore.token
if (!token) {
console.error('GarageStore: No authentication token available')
throw new Error('Not authenticated')
}
console.log('GarageStore: Starting vehicle fetch with token', token.substring(0, 20) + '...')
// Call real backend API
// First try the assets endpoint for user's vehicles
const apiUrl = `${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/assets/vehicles`
console.log('GarageStore: Fetching from primary endpoint:', apiUrl)
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
console.log('GarageStore: Primary endpoint response status:', response.status, response.statusText)
if (!response.ok) {
// If 404, try alternative endpoint
if (response.status === 404) {
console.log('GarageStore: Primary endpoint returned 404, trying user assets endpoint')
// Try user assets endpoint
const userApiUrl = `${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/users/me/assets`
const userResponse = await fetch(userApiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
})
console.log('GarageStore: User assets endpoint response status:', userResponse.status, userResponse.statusText)
if (!userResponse.ok) {
let errorBody = ''
try {
errorBody = await userResponse.text()
} catch (e) {
errorBody = 'Could not read error response'
}
console.error('GarageStore: User assets endpoint failed:', {
status: userResponse.status,
statusText: userResponse.statusText,
body: errorBody
})
throw new Error(`Failed to fetch vehicles: ${userResponse.status} ${userResponse.statusText}`)
}
const data = await userResponse.json()
console.log('GarageStore: Fetched vehicles from user assets endpoint', data)
vehicles.value = transformApiResponse(data)
} else {
let errorBody = ''
try {
errorBody = await response.text()
} catch (e) {
errorBody = 'Could not read error response'
}
console.error('GarageStore: Primary endpoint failed:', {
status: response.status,
statusText: response.statusText,
body: errorBody
})
throw new Error(`Failed to fetch vehicles: ${response.status} ${response.statusText}`)
}
} else {
const data = await response.json()
console.log('GarageStore: Fetched vehicles from assets endpoint', data)
vehicles.value = transformApiResponse(data)
}
} catch (err) {
console.error('GarageStore: Error fetching vehicles', err)
console.error('GarageStore: Full error stack:', err.stack)
error.value = err.message
// NO MORE MOCK DATA FALLBACK - fail properly
vehicles.value = []
} finally {
isLoading.value = false
}
console.log('GarageStore: Fetch completed, vehicles count:', vehicles.value.length)
return vehicles.value
}
// Helper function to transform API response to frontend format
function transformApiResponse(data) {
if (!Array.isArray(data)) {
// If single object, wrap in array
data = [data]
}
return data.map(item => {
// Safely extract catalog properties with optional chaining
const catalog = item.catalog || {}
const catalogMake = catalog?.make || catalog?.brand || null
const catalogModel = catalog?.model || null
const catalogFuelType = catalog?.fuel_type || catalog?.fuel || null
const catalogYear = catalog?.year || catalog?.year_of_manufacture || null
// Safely extract profile completion percentage
const profileCompletion = item.profile_completion_percentage ||
item.profile_completion ||
(item.profile_status === 'complete' ? 100 : 0) || 0
// Extract data_status from API (new field for vehicle data completeness)
const dataStatus = item.data_status || item.status || (item.is_active ? 'active' : 'draft')
return {
id: item.id || item.asset_id || 0,
make: item.make || item.brand || item.vehicle_make || catalogMake || 'Unknown',
model: item.model || item.vehicle_model || catalogModel || 'Unknown',
year: item.year || item.year_of_manufacture || item.manufacture_year || catalogYear || 2023,
licensePlate: item.license_plate || item.registration_number || 'N/A',
status: item.status || (item.is_active ? 'active' : 'draft'),
data_status: dataStatus,
profile_completion_percentage: profileCompletion,
monthlyExpense: item.monthly_expense || item.average_monthly_cost || 0,
fuelType: item.fuel_type || item.fuel || catalogFuelType || 'Unknown',
mileage: item.mileage || item.current_mileage || item.odometer_reading || 0,
imageUrl: item.image_url || item.photo_url || getDefaultImage(item.make || item.brand || catalogMake),
// Include catalog object for template access with null safety
catalog: catalog || null
}
})
}
// Helper function to get default image based on make
function getDefaultImage(make) {
// Handle null/undefined make with optional chaining and fallback
const makeLower = (make || '').toLowerCase()
if (makeLower.includes('bmw')) {
return 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=400&h=300&fit=crop'
} else if (makeLower.includes('audi')) {
return 'https://images.unsplash.com/photo-1553440569-bcc63803a83d?w=400&h=300&fit=crop'
} else if (makeLower.includes('mercedes')) {
return 'https://images.unsplash.com/photo-1563720223485-8d6d5c5c8c3b?w=400&h=300&fit=crop'
} else if (makeLower.includes('tesla')) {
return 'https://images.unsplash.com/photo-1560958089-b8a1929cea89?w=400&h=300&fit=crop'
}
return 'https://images.unsplash.com/photo-1549399542-7e3f8b79c341?w=400&h=300&fit=crop'
}
return {
vehicles,
isLoading,
error,
totalVehicles,
totalMonthlyExpense,
vehiclesNeedingService,
addVehicle,
removeVehicle,
updateVehicle,
getVehicleById,
fetchVehicles
}
})

View File

@@ -0,0 +1,261 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useQuizStore = defineStore('quiz', () => {
// State
const userPoints = ref(0)
const currentStreak = ref(0)
const lastPlayedDate = ref(null)
const questions = ref([])
const isLoading = ref(false)
const error = ref(null)
// Getters
const canPlayToday = computed(() => {
if (!lastPlayedDate.value) return true
const last = new Date(lastPlayedDate.value)
const now = new Date()
// Reset at midnight (different calendar day)
const lastDay = last.toDateString()
const today = now.toDateString()
return lastDay !== today
})
const totalQuestions = computed(() => questions.value.length)
// Helper function to get auth token
function getAuthToken() {
if (typeof window !== 'undefined') {
// Try both token keys for compatibility
return localStorage.getItem('token') || localStorage.getItem('auth_token')
}
return null
}
// Helper function for API calls
async function apiFetch(url, options = {}) {
const token = getAuthToken()
const headers = {
'Content-Type': 'application/json',
...options.headers
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}${url}`, {
...options,
headers
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API error ${response.status}: ${errorText}`)
}
return response.json()
}
// Actions
async function fetchQuizStats() {
isLoading.value = true
error.value = null
try {
const data = await apiFetch('/gamification/quiz/stats')
userPoints.value = data.total_quiz_points || 0
currentStreak.value = data.current_streak || 0
lastPlayedDate.value = data.last_played || null
return data
} catch (err) {
console.error('Failed to fetch quiz stats:', err)
error.value = err.message
// Fallback to localStorage if API fails
userPoints.value = getStoredPoints()
currentStreak.value = getStoredStreak()
lastPlayedDate.value = getStoredLastPlayedDate()
return {
total_quiz_points: userPoints.value,
current_streak: currentStreak.value,
last_played: lastPlayedDate.value,
can_play_today: canPlayToday.value
}
} finally {
isLoading.value = false
}
}
async function fetchDailyQuiz() {
isLoading.value = true
error.value = null
try {
const data = await apiFetch('/gamification/quiz/daily')
questions.value = data.questions || []
return data
} catch (err) {
console.error('Failed to fetch daily quiz:', err)
error.value = err.message
// Fallback to mock questions if API fails
questions.value = getMockQuestions()
return {
questions: questions.value,
total_questions: questions.value.length,
date: new Date().toISOString().split('T')[0]
}
} finally {
isLoading.value = false
}
}
async function answerQuestion(questionId, selectedOptionIndex) {
try {
const response = await apiFetch('/gamification/quiz/answer', {
method: 'POST',
body: JSON.stringify({
question_id: questionId,
selected_option: selectedOptionIndex
})
})
if (response.is_correct) {
userPoints.value += response.points_awarded
currentStreak.value += 1
persistState() // Update localStorage as fallback
} else {
currentStreak.value = 0
}
return response
} catch (err) {
console.error('Failed to submit quiz answer:', err)
error.value = err.message
// Fallback to local logic
return answerQuestionLocal(questionId, selectedOptionIndex)
}
}
async function completeDailyQuiz() {
try {
await apiFetch('/gamification/quiz/complete', {
method: 'POST'
})
lastPlayedDate.value = new Date().toISOString()
persistState()
} catch (err) {
console.error('Failed to complete daily quiz:', err)
error.value = err.message
// Fallback to local storage
lastPlayedDate.value = new Date().toISOString()
persistState()
}
}
// Local fallback functions
function answerQuestionLocal(questionId, selectedOptionIndex) {
const question = questions.value.find(q => q.id === questionId)
if (!question) return { is_correct: false, correct_answer: -1, explanation: 'Question not found' }
const isCorrect = selectedOptionIndex === question.correctAnswer
if (isCorrect) {
userPoints.value += 10
currentStreak.value += 1
} else {
currentStreak.value = 0
}
persistState()
return {
is_correct: isCorrect,
correct_answer: question.correctAnswer,
points_awarded: isCorrect ? 10 : 0,
explanation: question.explanation
}
}
function resetStreak() {
currentStreak.value = 0
persistState()
}
function addPoints(points) {
userPoints.value += points
persistState()
}
// SSR-safe localStorage persistence (fallback only)
function persistState() {
if (typeof window !== 'undefined') {
localStorage.setItem('quiz_points', userPoints.value.toString())
localStorage.setItem('quiz_streak', currentStreak.value.toString())
localStorage.setItem('quiz_last_played', lastPlayedDate.value)
}
}
function getStoredPoints() {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem('quiz_points') || '0')
}
return 0
}
function getStoredStreak() {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem('quiz_streak') || '0')
}
return 0
}
function getStoredLastPlayedDate() {
if (typeof window !== 'undefined') {
return localStorage.getItem('quiz_last_played') || null
}
return null
}
function getMockQuestions() {
return [
{
id: 1,
question: 'Melyik alkatrész felelős a motor levegőüzemanyag keverékének szabályozásáért?',
options: ['Generátor', 'Lambdaszonda', 'Féktárcsa', 'Olajszűrő'],
correctAnswer: 1,
explanation: 'A lambdaszonda méri a kipufogógáz oxigéntartalmát, és ezen alapul a befecskendezés.'
},
{
id: 2,
question: 'Mennyi ideig érvényes egy gépjármű műszaki vizsgája Magyarországon?',
options: ['1 év', '2 év', '4 év', '6 év'],
correctAnswer: 1,
explanation: 'A személygépkocsik műszaki vizsgája 2 évre érvényes, kivéve az újonnan forgalomba helyezett autókat.'
},
{
id: 3,
question: 'Melyik anyag NEM része a hibrid autók akkumulátorának?',
options: ['Lítium', 'Nikkel', 'Ólom', 'Kobalt'],
correctAnswer: 2,
explanation: 'A hibrid és elektromos autók akkumulátoraiban általában lítium, nikkel és kobalt található, ólom az ólomsavas akkukban van.'
}
]
}
// Initialize store with stats - DISABLED for debugging
// fetchQuizStats()
console.log('🚨 Quiz store: Auto-fetch DISABLED for debugging')
return {
userPoints,
currentStreak,
lastPlayedDate,
questions,
canPlayToday,
totalQuestions,
isLoading,
error,
fetchQuizStats,
fetchDailyQuiz,
answerQuestion,
completeDailyQuiz,
resetStreak,
addPoints
}
})

View File

@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
export const useThemeStore = defineStore('theme', {
state: () => ({
currentTheme: 'luxury_showroom', // 'luxury_showroom' or 'rusty_workshop'
}),
getters: {
isLuxury: (state) => state.currentTheme === 'luxury_showroom',
isWorkshop: (state) => state.currentTheme === 'rusty_workshop',
themeClasses: (state) => {
if (state.currentTheme === 'luxury_showroom') {
return {
background: 'bg-gradient-to-br from-slate-900 via-slate-800 to-gray-900',
text: 'text-amber-100',
accent: 'text-amber-400',
border: 'border-amber-700',
card: 'bg-slate-800/70 backdrop-blur-lg border border-amber-700/30',
button: 'bg-amber-700 hover:bg-amber-600 text-white',
}
} else {
return {
background: 'bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900',
text: 'text-orange-100',
accent: 'text-orange-400',
border: 'border-orange-800',
card: 'bg-gray-800/90 border border-dashed border-orange-700/50',
button: 'bg-orange-800 hover:bg-orange-700 text-white',
}
}
},
},
actions: {
toggleTheme() {
this.currentTheme = this.currentTheme === 'luxury_showroom' ? 'rusty_workshop' : 'luxury_showroom'
},
setTheme(theme) {
if (['luxury_showroom', 'rusty_workshop'].includes(theme)) {
this.currentTheme = theme
}
},
},
persist: true, // optional: if using pinia-plugin-persistedstate
})

Some files were not shown because too many files have changed in this diff Show More