admin firs step

This commit is contained in:
Roo
2026-03-23 21:43:40 +00:00
parent 309a72cc0b
commit cddcd34ba9
47 changed files with 22698 additions and 19 deletions

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

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

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: #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>

File diff suppressed because it is too large Load Diff