admin firs step
This commit is contained in:
604
frontend/admin/pages/dashboard.vue
Normal file
604
frontend/admin/pages/dashboard.vue
Normal file
@@ -0,0 +1,604 @@
|
||||
<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">
|
||||
<v-card-title class="text-h6 d-flex align-center">
|
||||
<v-icon icon="mdi-database" class="mr-2"></v-icon>
|
||||
Total Assets
|
||||
<v-spacer></v-spacer>
|
||||
<v-progress-circular
|
||||
v-if="healthMonitor.loading && !healthMonitor.metrics"
|
||||
indeterminate
|
||||
size="20"
|
||||
width="2"
|
||||
></v-progress-circular>
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h4 font-weight-bold text-primary">
|
||||
{{ healthMonitor.metrics?.total_assets?.toLocaleString() || '--' }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>Vehicles, services, and organizations</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Total Organizations -->
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title class="text-h6 d-flex align-center">
|
||||
<v-icon icon="mdi-office-building" class="mr-2"></v-icon>
|
||||
Organizations
|
||||
<v-spacer></v-spacer>
|
||||
<v-progress-circular
|
||||
v-if="healthMonitor.loading && !healthMonitor.metrics"
|
||||
indeterminate
|
||||
size="20"
|
||||
width="2"
|
||||
></v-progress-circular>
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h4 font-weight-bold text-success">
|
||||
{{ healthMonitor.metrics?.total_organizations?.toLocaleString() || '--' }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>Registered business entities</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Critical Alerts -->
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title class="text-h6 d-flex align-center">
|
||||
<v-icon icon="mdi-alert" class="mr-2"></v-icon>
|
||||
Critical Alerts (24h)
|
||||
<v-spacer></v-spacer>
|
||||
<v-progress-circular
|
||||
v-if="healthMonitor.loading && !healthMonitor.metrics"
|
||||
indeterminate
|
||||
size="20"
|
||||
width="2"
|
||||
></v-progress-circular>
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h4 font-weight-bold" :class="healthMonitor.metrics?.critical_alerts_24h ? 'text-error' : 'text-info'">
|
||||
{{ healthMonitor.metrics?.critical_alerts_24h || 0 }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>
|
||||
<span v-if="healthMonitor.metrics?.critical_alerts_24h">Requires immediate attention</span>
|
||||
<span v-else>No critical issues</span>
|
||||
</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- System Uptime -->
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title class="text-h6 d-flex align-center">
|
||||
<v-icon icon="mdi-heart-pulse" class="mr-2"></v-icon>
|
||||
System Uptime
|
||||
<v-spacer></v-spacer>
|
||||
<v-progress-circular
|
||||
v-if="healthMonitor.loading && !healthMonitor.metrics"
|
||||
indeterminate
|
||||
size="20"
|
||||
width="2"
|
||||
></v-progress-circular>
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h4 font-weight-bold text-warning">
|
||||
{{ healthMonitor.formattedUptime }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>
|
||||
Response: {{ healthMonitor.formattedResponseTime }}
|
||||
<v-icon
|
||||
v-if="healthMonitor.metrics?.response_time_ms < 100"
|
||||
icon="mdi-check"
|
||||
color="success"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
></v-icon>
|
||||
<v-icon
|
||||
v-else-if="healthMonitor.metrics?.response_time_ms < 300"
|
||||
icon="mdi-alert"
|
||||
color="warning"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
></v-icon>
|
||||
<v-icon
|
||||
v-else
|
||||
icon="mdi-alert-circle"
|
||||
color="error"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
></v-icon>
|
||||
</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Additional Metrics Row -->
|
||||
<v-row class="mt-2">
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title class="text-h6">
|
||||
<v-icon icon="mdi-account-group" class="mr-2"></v-icon>
|
||||
Active Users
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h3 font-weight-bold text-primary">
|
||||
{{ healthMonitor.metrics?.active_users?.toLocaleString() || '--' }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>Currently logged in users</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title class="text-h6">
|
||||
<v-icon icon="mdi-database-export" class="mr-2"></v-icon>
|
||||
DB Connections
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h3 font-weight-bold" :class="getDbConnectionClass(healthMonitor.metrics?.database_connections)">
|
||||
{{ healthMonitor.metrics?.database_connections || '--' }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>
|
||||
<span v-if="healthMonitor.metrics?.database_connections > 40" class="text-error">High load</span>
|
||||
<span v-else-if="healthMonitor.metrics?.database_connections > 20" class="text-warning">Moderate load</span>
|
||||
<span v-else class="text-success">Normal load</span>
|
||||
</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title class="text-h6">
|
||||
<v-icon icon="mdi-update" class="mr-2"></v-icon>
|
||||
Last Updated
|
||||
</v-card-title>
|
||||
<v-card-text class="text-h5 font-weight-bold text-grey">
|
||||
{{ healthMonitor.lastUpdated ? formatTime(healthMonitor.lastUpdated) : 'Never' }}
|
||||
</v-card-text>
|
||||
<v-card-subtitle>
|
||||
<v-icon icon="mdi-clock-outline" size="small" class="mr-1"></v-icon>
|
||||
Auto-refresh every 30s
|
||||
</v-card-subtitle>
|
||||
</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';
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
262
frontend/admin/pages/login.vue
Normal file
262
frontend/admin/pages/login.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<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="password"
|
||||
prepend-icon="mdi-lock"
|
||||
: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>
|
||||
|
||||
<!-- Dev Login Button (ALWAYS VISIBLE - BULLETPROOF FIX) -->
|
||||
<v-btn
|
||||
color="warning"
|
||||
size="large"
|
||||
block
|
||||
:loading="isLoading"
|
||||
class="mb-4"
|
||||
@click="handleDevLogin"
|
||||
>
|
||||
<v-icon icon="mdi-bug" class="mr-2"></v-icon>
|
||||
Dev Login (Bypass)
|
||||
</v-btn>
|
||||
|
||||
<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('moderator')">
|
||||
Moderator
|
||||
</v-chip>
|
||||
<v-chip size="small" variant="outlined" @click="setDemoCredentials('salesperson')">
|
||||
Salesperson
|
||||
</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'
|
||||
|
||||
// State
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
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
|
||||
const demoCredentials = {
|
||||
superadmin: {
|
||||
email: 'superadmin@servicefinder.com',
|
||||
password: 'superadmin123',
|
||||
role: 'superadmin',
|
||||
rank: 10,
|
||||
scope_level: 'global'
|
||||
},
|
||||
admin: {
|
||||
email: 'admin@servicefinder.com',
|
||||
password: 'admin123',
|
||||
role: 'admin',
|
||||
rank: 7,
|
||||
scope_level: 'region',
|
||||
region_code: 'HU-BU',
|
||||
scope_id: 123
|
||||
},
|
||||
moderator: {
|
||||
email: 'moderator@servicefinder.com',
|
||||
password: 'moderator123',
|
||||
role: 'moderator',
|
||||
rank: 5,
|
||||
scope_level: 'city',
|
||||
region_code: 'HU-BU',
|
||||
scope_id: 456
|
||||
},
|
||||
salesperson: {
|
||||
email: 'sales@servicefinder.com',
|
||||
password: 'sales123',
|
||||
role: 'salesperson',
|
||||
rank: 3,
|
||||
scope_level: 'district',
|
||||
region_code: 'HU-BU',
|
||||
scope_id: 789
|
||||
}
|
||||
}
|
||||
|
||||
// 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 dev login (bypass authentication)
|
||||
async function handleDevLogin() {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
console.log('[DEV MODE] Using development login bypass')
|
||||
|
||||
// Use the exact mock JWT string provided in the task
|
||||
const mockJwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdXBlcmFkbWluQHNlcnZpY2VmaW5kZXIuY29tIiwicm9sZSI6InN1cGVyYWRtaW4iLCJyYW5rIjoxMDAsInNjb3BlX2xldmVsIjoiZ2xvYmFsIiwiZXhwIjozMDAwMDAwMDAwLCJpYXQiOjE3MDAwMDAwMDB9.dummy_signature'
|
||||
|
||||
// Store token and parse
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('admin_token', mockJwtToken)
|
||||
}
|
||||
authStore.token = mockJwtToken
|
||||
authStore.parseToken()
|
||||
|
||||
// Navigate to dashboard
|
||||
navigateTo('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Dev login failed'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login
|
||||
async function handleLogin() {
|
||||
// Validate form
|
||||
const { valid } = await loginForm.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 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 {
|
||||
// Simulate API call for real credentials
|
||||
const success = await authStore.login(email.value, password.value)
|
||||
if (!success) {
|
||||
error.value = 'Invalid credentials. Please try again.'
|
||||
}
|
||||
}
|
||||
} catch (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>
|
||||
311
frontend/admin/pages/moderation-map.vue
Normal file
311
frontend/admin/pages/moderation-map.vue
Normal 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: #ffc107;
|
||||
}
|
||||
|
||||
.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: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.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>
|
||||
1247
frontend/admin/pages/users.vue
Normal file
1247
frontend/admin/pages/users.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user