Phase 1: API + Frontend Skelett

This commit is contained in:
Hermes Agent
2026-05-18 23:08:54 +02:00
parent aa9580c061
commit 4f0ab5c518
29 changed files with 3436 additions and 1 deletions

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="fahrschuldesk - Verwaltungssoftware für Fahrschulen" />
<title>fahrschuldesk</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"name": "fahrschuldesk-ui",
"description": "fahrschuldesk WebUI",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,31 @@
<template>
<div id="app" :style="customStyles">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const customStyles = computed(() => {
if (!auth.tenant) return {}
return {
'--color-primary': auth.tenant.primary_color || '#2563eb',
'--color-secondary': auth.tenant.secondary_color || '#64748b',
}
})
</script>
<style>
#app {
min-height: 100vh;
background-color: #f8fafc;
}
</style>

View File

@@ -0,0 +1,59 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor - add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401 and refresh token
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
// If 401 and not already retrying
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
const response = await axios.post('/api/auth/refresh', {
refresh_token: refreshToken
})
const { access_token, refresh_token } = response.data.data
localStorage.setItem('access_token', access_token)
localStorage.setItem('refresh_token', refresh_token)
// Retry original request
originalRequest.headers.Authorization = `Bearer ${access_token}`
return api(originalRequest)
} catch (refreshError) {
// Refresh failed, logout
localStorage.clear()
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,147 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { guest: true }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/Register.vue'),
meta: { guest: true }
},
{
path: '/forgot-password',
name: 'ForgotPassword',
component: () => import('@/views/auth/ForgotPassword.vue'),
meta: { guest: true }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
// Admin routes
{
path: '/admin/tenants',
name: 'Tenants',
component: () => import('@/views/admin/Tenants.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/admin/tenants/:id',
name: 'TenantEdit',
component: () => import('@/views/admin/TenantEdit.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/admin/modules',
name: 'Modules',
component: () => import('@/views/admin/Modules.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/admin/audit-logs',
name: 'AuditLogs',
component: () => import('@/views/admin/AuditLogs.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/admin/invoices',
name: 'Invoices',
component: () => import('@/views/admin/Invoices.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/admin/admins',
name: 'PlatformAdmins',
component: () => import('@/views/admin/PlatformAdmins.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
// Tenant routes
{
path: '/branches',
name: 'Branches',
component: () => import('@/views/tenant/Branches.vue'),
meta: { requiresAuth: true, module: 'office' }
},
{
path: '/users',
name: 'Users',
component: () => import('@/views/tenant/Users.vue'),
meta: { requiresAuth: true, module: 'office' }
},
{
path: '/calendar',
name: 'Calendar',
component: () => import('@/views/tenant/Calendar.vue'),
meta: { requiresAuth: true, module: 'calendar' }
},
{
path: '/messenger',
name: 'Messenger',
component: () => import('@/views/tenant/Messenger.vue'),
meta: { requiresAuth: true, module: 'messenger' }
},
{
path: '/learning',
name: 'Learning',
component: () => import('@/views/tenant/Learning.vue'),
meta: { requiresAuth: true, module: ['learning_blank', 'learning_premium'] }
},
{
path: '/files',
name: 'Files',
component: () => import('@/views/tenant/Files.vue'),
meta: { requiresAuth: true, module: 'office' }
},
{
path: '/branding',
name: 'Branding',
component: () => import('@/views/tenant/Branding.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
// Catch all
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
if (to.meta.adminOnly && !auth.isPlatformAdmin) {
next({ name: 'Dashboard' })
return
}
if (to.meta.guest && auth.isAuthenticated) {
next({ name: 'Dashboard' })
return
}
next()
})
export default router

View File

@@ -0,0 +1,124 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const tenant = ref(null)
const accessToken = ref(localStorage.getItem('access_token') || null)
const refreshToken = ref(localStorage.getItem('refresh_token') || null)
const isAuthenticated = computed(() => !!accessToken.value)
const isPlatformAdmin = computed(() => user.value?.type === 'platform_admin')
const isTenantAdmin = computed(() => user.value?.user_type === 'admin')
async function login(email, password) {
try {
const response = await api.post('/auth/login', { email, password })
const { data } = response.data
accessToken.value = data.access_token
refreshToken.value = data.refresh_token
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
await fetchUser()
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.message || 'Login failed'
}
}
}
async function register(payload) {
try {
const response = await api.post('/auth/register', payload)
return { success: true, data: response.data.data }
} catch (error) {
return {
success: false,
message: error.response?.data?.message || 'Registration failed',
errors: error.response?.data?.errors || {}
}
}
}
async function fetchUser() {
try {
const response = await api.get('/auth/me')
user.value = response.data.data
if (user.value.tenant_id) {
tenant.value = {
id: user.value.tenant_id,
name: user.value.tenant_name,
slug: user.value.tenant_slug,
status: user.value.tenant_status,
primary_color: user.value.primary_color,
secondary_color: user.value.secondary_color,
}
}
} catch (error) {
console.error('Failed to fetch user:', error)
logout()
}
}
async function refreshAccessToken() {
if (!refreshToken.value) return false
try {
const response = await api.post('/auth/refresh', {
refresh_token: refreshToken.value
})
const { data } = response.data
accessToken.value = data.access_token
refreshToken.value = data.refresh_token
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
return true
} catch (error) {
logout()
return false
}
}
function logout() {
user.value = null
tenant.value = null
accessToken.value = null
refreshToken.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
// Initialize
if (accessToken.value) {
fetchUser()
}
return {
user,
tenant,
accessToken,
refreshToken,
isAuthenticated,
isPlatformAdmin,
isTenantAdmin,
login,
register,
fetchUser,
refreshAccessToken,
logout
}
})

View File

@@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2563eb;
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-secondary: #64748b;
--color-secondary-500: #64748b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(20px);
opacity: 0;
}

View File

@@ -0,0 +1,135 @@
<template>
<div class="min-h-screen bg-slate-100">
<!-- Header -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-primary flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</div>
<span class="text-lg font-bold text-slate-800">fahrschuldesk</span>
</div>
<!-- Navigation -->
<nav class="hidden md:flex items-center gap-1">
<router-link
to="/dashboard"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
:class="$route.path === '/dashboard' ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
>
Dashboard
</router-link>
<template v-if="auth.isPlatformAdmin">
<router-link
to="/admin/tenants"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
:class="$route.path.startsWith('/admin/tenants') ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
>
Fahrschulen
</router-link>
<router-link
to="/admin/invoices"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
:class="$route.path.startsWith('/admin/invoices') ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
>
Rechnungen
</router-link>
<router-link
to="/admin/audit-logs"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
:class="$route.path.startsWith('/admin/audit') ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
>
Audit-Log
</router-link>
</template>
<template v-else>
<router-link
v-if="hasModule('office')"
to="/users"
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
>
Benutzer
</router-link>
<router-link
v-if="hasModule('calendar')"
to="/calendar"
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
>
Kalender
</router-link>
<router-link
v-if="hasModule('messenger')"
to="/messenger"
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
>
Messenger
</router-link>
<router-link
v-if="hasModule('learning_blank') || hasModule('learning_premium')"
to="/learning"
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
>
Lernsoftware
</router-link>
</template>
</nav>
<!-- User Menu -->
<div class="flex items-center gap-3">
<div class="text-right hidden sm:block">
<p class="text-sm font-medium text-slate-800">{{ auth.user?.name || auth.user?.first_name }}</p>
<p class="text-xs text-slate-500">{{ tenantLabel }}</p>
</div>
<button
@click="handleLogout"
class="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
title="Abmelden"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<router-view />
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const tenantLabel = computed(() => {
if (auth.isPlatformAdmin) return 'Plattform-Admin'
return auth.tenant?.name || ''
})
function hasModule(moduleKey) {
if (auth.isPlatformAdmin) return true
// TODO: check tenant modules
return true
}
async function handleLogout() {
auth.logout()
router.push('/login')
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="text-center">
<div class="text-8xl font-bold text-slate-200 mb-4">404</div>
<h1 class="text-2xl font-bold text-slate-800 mb-2">Seite nicht gefunden</h1>
<p class="text-slate-500 mb-8">Die Seite, die du suchst, existiert nicht.</p>
<router-link
to="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-600 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Zum Dashboard
</router-link>
</div>
</div>
</template>

View File

@@ -0,0 +1,280 @@
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Fahrschulen</h1>
<p class="text-slate-500 text-sm mt-1">Verwalten Sie alle registrierten Fahrschulen</p>
</div>
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-600 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Fahrschule anlegen
</button>
</div>
<!-- Filters -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<input
v-model="search"
type="text"
placeholder="Suchen..."
class="w-full pl-10 pr-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
/>
</div>
<select
v-model="statusFilter"
class="px-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
>
<option value="">Alle Status</option>
<option value="pending_approval">Ausstehend</option>
<option value="active">Aktiv</option>
<option value="trial">Testphase</option>
<option value="suspended">Suspendiert</option>
</select>
</div>
</div>
<!-- Table -->
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Fahrschule</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Module</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Registriert</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-slate-600 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
<tr v-for="tenant in filteredTenants" :key="tenant.id" class="hover:bg-slate-50">
<td class="px-6 py-4">
<div>
<p class="font-medium text-slate-800">{{ tenant.name }}</p>
<p class="text-sm text-slate-500">{{ tenant.email }}</p>
</div>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="statusClass(tenant.status)"
>
{{ statusLabel(tenant.status) }}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-1">
<span
v-for="mod in tenant.modules?.slice(0, 3)"
:key="mod"
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600"
>
{{ mod }}
</span>
<span v-if="tenant.module_count > 3" class="text-xs text-slate-400">
+{{ tenant.module_count - 3 }}
</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-slate-500">
{{ formatDate(tenant.created_at) }}
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<router-link
:to="`/admin/tenants/${tenant.id}`"
class="p-2 text-slate-400 hover:text-primary rounded-lg hover:bg-slate-100 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</router-link>
<button
v-if="tenant.status === 'pending_approval'"
@click="approveTenant(tenant)"
class="p-2 text-slate-400 hover:text-green-600 rounded-lg hover:bg-green-50 transition-colors"
title="Genehmigen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<button
v-if="tenant.status !== 'suspended'"
@click="suspendTenant(tenant)"
class="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
title="Suspendieren"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="filteredTenants.length === 0">
<td colspan="5" class="px-6 py-12 text-center text-slate-500">
Keine Fahrschulen gefunden
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-slate-200 flex items-center justify-between">
<p class="text-sm text-slate-500">
Zeige {{ pagination.start }} - {{ pagination.end }} von {{ pagination.total }}
</p>
<div class="flex items-center gap-2">
<button
@click="page--"
:disabled="page === 1"
class="px-3 py-1 rounded border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurück
</button>
<button
@click="page++"
:disabled="page >= pagination.pages"
class="px-3 py-1 rounded border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
</div>
<!-- Create Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-slate-800 mb-4">Fahrschule anlegen</h3>
<form @submit.prevent="createTenant" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input v-model="newTenant.name" type="text" required class="w-full px-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
<input v-model="newTenant.email" type="email" required class="w-full px-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary outline-none" />
</div>
<div class="flex items-center gap-2 pt-2">
<button type="button" @click="showCreateModal = false" class="px-4 py-2 rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50">
Abbrechen
</button>
<button type="submit" :disabled="creating" class="px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary-600 disabled:opacity-50">
{{ creating ? 'Erstelle...' : 'Erstellen' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import api from '@/api/client'
const tenants = ref([])
const loading = ref(true)
const search = ref('')
const statusFilter = ref('')
const page = ref(1)
const limit = ref(20)
const showCreateModal = ref(false)
const newTenant = reactive({ name: '', email: '' })
const creating = ref(false)
const filteredTenants = computed(() => {
return tenants.value
})
const pagination = computed(() => ({
start: (page.value - 1) * limit.value + 1,
end: Math.min(page.value * limit.value, tenants.value.length),
total: tenants.value.length,
pages: Math.ceil(tenants.value.length / limit.value)
}))
async function fetchTenants() {
loading.value = true
try {
const response = await api.get('/admin/tenants', { params: { page: page.value, limit: limit.value, status: statusFilter.value || undefined } })
tenants.value = response.data.data.tenants
} catch (error) {
console.error('Failed to fetch tenants:', error)
} finally {
loading.value = false
}
}
async function createTenant() {
creating.value = true
try {
await api.post('/admin/tenants', newTenant)
showCreateModal.value = false
Object.assign(newTenant, { name: '', email: '' })
await fetchTenants()
} catch (error) {
console.error('Failed to create tenant:', error)
} finally {
creating.value = false
}
}
async function approveTenant(tenant) {
try {
await api.post(`/admin/tenants/${tenant.id}/approve`)
await fetchTenants()
} catch (error) {
console.error('Failed to approve tenant:', error)
}
}
async function suspendTenant(tenant) {
try {
await api.post(`/admin/tenants/${tenant.id}/suspend`)
await fetchTenants()
} catch (error) {
console.error('Failed to suspend tenant:', error)
}
}
function statusClass(status) {
const classes = {
pending_approval: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
trial: 'bg-blue-100 text-blue-800',
suspended: 'bg-red-100 text-red-800'
}
return classes[status] || 'bg-slate-100 text-slate-800'
}
function statusLabel(status) {
const labels = {
pending_approval: 'Ausstehend',
active: 'Aktiv',
trial: 'Testphase',
suspended: 'Suspendiert'
}
return labels[status] || status
}
function formatDate(date) {
return new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
onMounted(fetchTenants)
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 px-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary mb-4">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h2 class="text-xl font-bold text-slate-800">Passwort vergessen?</h2>
<p class="text-slate-500 text-sm mt-1">Kein Problem. Geben Sie Ihre E-Mail ein.</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">E-Mail</label>
<input
v-model="email"
type="email"
required
placeholder="ihre@email.de"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
/>
</div>
<div v-if="message" class="p-3 rounded-lg text-sm" :class="success ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'">
{{ message }}
</div>
<button
type="submit"
:disabled="loading"
class="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 disabled:opacity-50"
>
{{ loading ? 'Senden...' : 'Link senden' }}
</button>
</form>
<p class="text-center text-sm text-slate-500 mt-6">
Zurück zum <router-link to="/login" class="text-primary hover:underline">Login</router-link>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '@/api/client'
const email = ref('')
const loading = ref(false)
const message = ref('')
const success = ref(false)
async function handleSubmit() {
loading.value = true
message.value = ''
try {
await api.post('/auth/forgot-password', { email: email.value })
success.value = true
message.value = 'Wenn die E-Mail existiert, erhalten Sie einen Link zum Zurücksetzen.'
} catch {
success.value = true
message.value = 'Wenn die E-Mail existiert, erhalten Sie einen Link zum Zurücksetzen.'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 px-4">
<div class="w-full max-w-md">
<!-- Logo & Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary mb-4">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</div>
<h1 class="text-2xl font-bold text-slate-800">fahrschuldesk</h1>
<p class="text-slate-500 mt-1">Verwaltung für Fahrschulen</p>
</div>
<!-- Login Card -->
<div class="bg-white rounded-2xl shadow-xl p-8">
<h2 class="text-xl font-semibold text-slate-800 mb-6">Anmelden</h2>
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
<input
v-model="form.email"
type="email"
placeholder="ihre@email.de"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
:class="{ 'border-red-400 focus:ring-red-200': errors.email }"
:disabled="loading"
/>
<p v-if="errors.email" class="mt-1 text-sm text-red-500">{{ errors.email[0] }}</p>
</div>
<!-- Password -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label>
<div class="relative">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
placeholder="••••••••"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all pr-12"
:class="{ 'border-red-400 focus:ring-red-200': errors.password }"
:disabled="loading"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<svg v-if="showPassword" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
<p v-if="errors.password" class="mt-1 text-sm text-red-500">{{ errors.password[0] }}</p>
</div>
<!-- Error -->
<div v-if="errorMessage" class="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
{{ errorMessage }}
</div>
<!-- Submit -->
<button
type="submit"
:disabled="loading"
class="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 focus:ring-4 focus:ring-primary-200 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="loading" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Anmelden...
</span>
<span v-else>Anmelden</span>
</button>
</form>
<!-- Links -->
<div class="mt-6 flex items-center justify-between text-sm">
<router-link to="/forgot-password" class="text-primary hover:underline">
Passwort vergessen?
</router-link>
<router-link to="/register" class="text-slate-500 hover:text-slate-700">
Registrieren
</router-link>
</div>
</div>
<!-- Demo hint -->
<p class="text-center text-slate-400 text-sm mt-6">
Noch kein Konto?
<router-link to="/register" class="text-primary hover:underline">Jetzt Fahrschule registrieren</router-link>
</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const form = reactive({
email: '',
password: ''
})
const errors = reactive({})
const errorMessage = ref('')
const loading = ref(false)
const showPassword = ref(false)
async function handleLogin() {
errorMessage.value = ''
Object.keys(errors).forEach(k => delete errors[k])
loading.value = true
const result = await auth.login(form.email, form.password)
loading.value = false
if (result.success) {
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} else {
errorMessage.value = result.message
}
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 px-4 py-12">
<div class="w-full max-w-lg">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary mb-4">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</div>
<h1 class="text-2xl font-bold text-slate-800">Fahrschule registrieren</h1>
<p class="text-slate-500 mt-1">Starten Sie Ihre 14-tägige kostenlose Testphase</p>
</div>
<!-- Form Card -->
<div class="bg-white rounded-2xl shadow-xl p-8">
<form @submit.prevent="handleRegister" class="space-y-5">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Name der Fahrschule *</label>
<input
v-model="form.name"
type="text"
placeholder="Muster Fahrschule"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
:class="{ 'border-red-400': errors.name }"
:disabled="loading"
/>
<p v-if="errors.name" class="mt-1 text-sm text-red-500">{{ errors.name[0] }}</p>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">E-Mail *</label>
<input
v-model="form.email"
type="email"
placeholder="info@fahrschule.de"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
:class="{ 'border-red-400': errors.email }"
:disabled="loading"
/>
<p v-if="errors.email" class="mt-1 text-sm text-red-500">{{ errors.email[0] }}</p>
</div>
<!-- Phone -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Telefon</label>
<input
v-model="form.phone"
type="tel"
placeholder="+49 123 456789"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
:disabled="loading"
/>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Adresse</label>
<textarea
v-model="form.address"
rows="2"
placeholder="Straße, PLZ Ort"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all resize-none"
:disabled="loading"
/>
</div>
<!-- Password -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Passwort *</label>
<input
v-model="form.password"
type="password"
placeholder="Mindestens 8 Zeichen"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
:class="{ 'border-red-400': errors.password }"
:disabled="loading"
/>
<p v-if="errors.password" class="mt-1 text-sm text-red-500">{{ errors.password[0] }}</p>
</div>
<!-- Password Confirm -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen *</label>
<input
v-model="form.password_confirmation"
type="password"
placeholder="Passwort wiederholen"
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
:disabled="loading"
/>
</div>
<!-- Terms -->
<div class="flex items-start gap-3">
<input
v-model="form.accept_terms"
type="checkbox"
id="terms"
class="mt-1 rounded border-slate-300 text-primary focus:ring-primary"
:disabled="loading"
/>
<label for="terms" class="text-sm text-slate-600">
Ich akzeptiere die
<a href="#" class="text-primary hover:underline">AGB</a> und
<a href="#" class="text-primary hover:underline">Datenschutzerklärung</a>
</label>
</div>
<!-- Error -->
<div v-if="errorMessage" class="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
{{ errorMessage }}
</div>
<!-- Success -->
<div v-if="successMessage" class="p-3 rounded-lg bg-green-50 text-green-600 text-sm">
{{ successMessage }}
</div>
<!-- Submit -->
<button
type="submit"
:disabled="loading || !form.accept_terms"
class="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 focus:ring-4 focus:ring-primary-200 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="loading" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Registrieren...
</span>
<span v-else>Jetzt kostenlos testen</span>
</button>
</form>
<p class="text-center text-sm text-slate-500 mt-6">
Bereits registriert?
<router-link to="/login" class="text-primary hover:underline">Jetzt anmelden</router-link>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const form = reactive({
name: '',
email: '',
phone: '',
address: '',
password: '',
password_confirmation: '',
accept_terms: false
})
const errors = reactive({})
const errorMessage = ref('')
const successMessage = ref('')
const loading = ref(false)
async function handleRegister() {
errorMessage.value = ''
successMessage.value = ''
Object.keys(errors).forEach(k => delete errors[k])
if (form.password !== form.password_confirmation) {
errors.password_confirmation = ['Passwörter stimmen nicht überein']
return
}
loading.value = true
const result = await auth.register({
name: form.name,
email: form.email,
phone: form.phone,
address: form.address,
password: form.password
})
loading.value = false
if (result.success) {
successMessage.value = 'Registrierung erfolgreich! Nach der Freigabe durch einen Admin erhalten Sie eine E-Mail.'
setTimeout(() => {
router.push('/login')
}, 3000)
} else {
errorMessage.value = result.message
if (result.errors) {
Object.assign(errors, result.errors)
}
}
}
</script>

View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'var(--color-primary)',
50: 'var(--color-primary-50)',
100: 'var(--color-primary-100)',
500: 'var(--color-primary-500)',
600: 'var(--color-primary-600)',
700: 'var(--color-primary-700)',
},
secondary: {
DEFAULT: 'var(--color-secondary)',
500: 'var(--color-secondary-500)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'https://api.fahrschuldesk.de',
changeOrigin: true
}
}
}
})