diff --git a/README.md b/README.md index 3ac3eb0..7f30789 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,90 @@ -# secu-frontend +# SeCu Frontend -SeCu Frontend - Vue 3 + TailwindCSS \ No newline at end of file +Vue 3 + TailwindCSS Frontend für die SeCu Mitarbeiterverwaltung. + +## Tech Stack + +- **Framework:** Vue 3 (Composition API) +- **Build:** Vite +- **Styling:** TailwindCSS +- **State:** Pinia +- **Routing:** Vue Router + +## Setup + +```bash +# Dependencies installieren +npm install + +# Development Server starten +npm run dev + +# Production Build +npm run build +``` + +## Umgebungsvariablen + +Erstelle `.env.local`: + +``` +VITE_API_URL=http://localhost:8004/api +``` + +Für Production: +``` +VITE_API_URL=https://api.secu.kronos-soulution.de/api +``` + +## Struktur + +``` +src/ +├── api/ # API Client +├── assets/ # CSS & statische Dateien +├── components/ +│ ├── common/ # Wiederverwendbare Komponenten +│ └── layout/ # Layout-Komponenten (Sidebar, Header) +├── composables/ # Vue Composables +├── router/ # Vue Router Konfiguration +├── stores/ # Pinia Stores +│ └── auth.ts # Authentifizierung +└── views/ # Seiten + ├── LoginView.vue + ├── DashboardView.vue + ├── OrdersView.vue + ├── OrderDetailView.vue + ├── UsersView.vue + ├── AvailabilityView.vue + ├── TimesheetsView.vue + ├── SettingsView.vue + └── ModulesView.vue +``` + +## Features + +- 🔐 **Login/Registrierung** mit JWT Auth +- 📊 **Dashboard** mit Statistiken +- 📋 **Aufträge** erstellen, bearbeiten, zuweisen +- 👥 **Mitarbeiter** verwalten (Chef/Disponent) +- 📅 **Verfügbarkeit** im Kalender melden +- ⏱️ **Stundenzettel** einreichen & genehmigen +- ⚙️ **Module** aktivieren/deaktivieren (Chef) +- 🌙 **Dark Mode** Support +- 📱 **Responsive** Design + +## Rollen + +| Rolle | Zugriff | +|-------|---------| +| Chef | Vollzugriff + Module verwalten | +| Disponent | Mitarbeiter + Aufträge + Stundenzettel | +| Mitarbeiter | Eigene Aufträge, Verfügbarkeit, Stundenzettel | + +## Port + +Development: `3006` + +--- + +*SeCu Frontend v1.0.0* diff --git a/index.html b/index.html new file mode 100644 index 0000000..09ff0c9 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + SeCu - Mitarbeiterverwaltung + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..030d7bf --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "secu-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "pinia": "^2.1.0", + "@vueuse/core": "^10.7.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0", + "tailwindcss": "^3.4.0", + "postcss": "^8.4.0", + "autoprefixer": "^10.4.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..bc84ba7 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..4fcb99c --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,106 @@ +const API_URL = import.meta.env.VITE_API_URL || '/api' + +interface ApiResponse { + data: T + status: number +} + +class ApiClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + private getToken(): string | null { + return localStorage.getItem('accessToken') + } + + private async request( + method: string, + endpoint: string, + data?: unknown + ): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + } + + const token = this.getToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + const config: RequestInit = { + method, + headers, + } + + if (data) { + config.body = JSON.stringify(data) + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, config) + + // Handle 401 - try to refresh token + if (response.status === 401) { + const refreshed = await this.tryRefreshToken() + if (refreshed) { + // Retry request with new token + headers['Authorization'] = `Bearer ${this.getToken()}` + const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, { + ...config, + headers, + }) + const retryData = await retryResponse.json() + return { data: retryData, status: retryResponse.status } + } + } + + const responseData = await response.json() + + if (!response.ok) { + throw new Error(responseData.error || 'Request failed') + } + + return { data: responseData, status: response.status } + } + + private async tryRefreshToken(): Promise { + const refreshToken = localStorage.getItem('refreshToken') + if (!refreshToken) return false + + try { + const response = await fetch(`${this.baseUrl}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + + if (!response.ok) return false + + const data = await response.json() + localStorage.setItem('accessToken', data.accessToken) + return true + } catch { + return false + } + } + + get(endpoint: string): Promise> { + return this.request('GET', endpoint) + } + + post(endpoint: string, data?: unknown): Promise> { + return this.request('POST', endpoint, data) + } + + put(endpoint: string, data?: unknown): Promise> { + return this.request('PUT', endpoint, data) + } + + delete(endpoint: string): Promise> { + return this.request('DELETE', endpoint) + } +} + +export const api = new ApiClient(API_URL) diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..e250143 --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply text-gray-900 dark:text-gray-100; + } +} + +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2; + } + + .btn-secondary { + @apply bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600; + } + + .btn-danger { + @apply bg-red-600 text-white hover:bg-red-700; + } + + .btn-success { + @apply bg-green-600 text-white hover:bg-green-700; + } + + .input { + @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:border-gray-600 dark:text-white; + } + + .card { + @apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6; + } + + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-primary { + @apply bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200; + } + + .badge-success { + @apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200; + } + + .badge-warning { + @apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200; + } + + .badge-danger { + @apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200; + } +} diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue new file mode 100644 index 0000000..9c6d374 --- /dev/null +++ b/src/components/layout/AppHeader.vue @@ -0,0 +1,92 @@ + + + diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue new file mode 100644 index 0000000..c01bbea --- /dev/null +++ b/src/components/layout/AppLayout.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..fe9218a --- /dev/null +++ b/src/components/layout/AppSidebar.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b257fe5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/main.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..a863642 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,88 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { guest: true } + }, + { + path: '/', + component: () => import('@/components/layout/AppLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'dashboard', + component: () => import('@/views/DashboardView.vue') + }, + { + path: 'orders', + name: 'orders', + component: () => import('@/views/OrdersView.vue') + }, + { + path: 'orders/:id', + name: 'order-detail', + component: () => import('@/views/OrderDetailView.vue') + }, + { + path: 'users', + name: 'users', + component: () => import('@/views/UsersView.vue'), + meta: { roles: ['chef', 'disponent'] } + }, + { + path: 'availability', + name: 'availability', + component: () => import('@/views/AvailabilityView.vue') + }, + { + path: 'timesheets', + name: 'timesheets', + component: () => import('@/views/TimesheetsView.vue') + }, + { + path: 'settings', + name: 'settings', + component: () => import('@/views/SettingsView.vue') + }, + { + path: 'modules', + name: 'modules', + component: () => import('@/views/ModulesView.vue'), + meta: { roles: ['chef'] } + } + ] + } + ] +}) + +router.beforeEach(async (to, _from, next) => { + const authStore = useAuthStore() + + // Try to restore session + if (!authStore.isAuthenticated && authStore.hasStoredToken) { + await authStore.fetchCurrentUser() + } + + const requiresAuth = to.matched.some(record => record.meta.requiresAuth) + const isGuest = to.matched.some(record => record.meta.guest) + const requiredRoles = to.meta.roles as string[] | undefined + + if (requiresAuth && !authStore.isAuthenticated) { + next({ name: 'login', query: { redirect: to.fullPath } }) + } else if (isGuest && authStore.isAuthenticated) { + next({ name: 'dashboard' }) + } else if (requiredRoles && !requiredRoles.includes(authStore.user?.role || '')) { + next({ name: 'dashboard' }) + } else { + next() + } +}) + +export default router diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..01d356a --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,128 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { api } from '@/api' + +export interface User { + id: string + email: string + role: 'chef' | 'disponent' | 'mitarbeiter' + first_name: string + last_name: string + org_id: string +} + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const accessToken = ref(localStorage.getItem('accessToken')) + const refreshToken = ref(localStorage.getItem('refreshToken')) + const orgSlug = ref(localStorage.getItem('orgSlug') || '') + + const isAuthenticated = computed(() => !!user.value && !!accessToken.value) + const hasStoredToken = computed(() => !!accessToken.value) + const isChef = computed(() => user.value?.role === 'chef') + const isDisponent = computed(() => user.value?.role === 'disponent') + const isMitarbeiter = computed(() => user.value?.role === 'mitarbeiter') + const canManageUsers = computed(() => isChef.value || isDisponent.value) + const canManageOrders = computed(() => isChef.value || isDisponent.value) + const fullName = computed(() => user.value ? `${user.value.first_name} ${user.value.last_name}` : '') + + function setTokens(access: string, refresh: string) { + accessToken.value = access + refreshToken.value = refresh + localStorage.setItem('accessToken', access) + localStorage.setItem('refreshToken', refresh) + } + + function setOrgSlug(slug: string) { + orgSlug.value = slug + localStorage.setItem('orgSlug', slug) + } + + function clearAuth() { + user.value = null + accessToken.value = null + refreshToken.value = null + localStorage.removeItem('accessToken') + localStorage.removeItem('refreshToken') + } + + async function login(email: string, password: string, org: string) { + const response = await api.post('/auth/login', { + email, + password, + org_slug: org + }) + + setTokens(response.data.accessToken, response.data.refreshToken) + setOrgSlug(org) + user.value = response.data.user + + return response.data + } + + async function register(data: { + email: string + password: string + first_name: string + last_name: string + phone?: string + org_slug: string + }) { + const response = await api.post('/auth/register', data) + return response.data + } + + async function logout() { + try { + await api.post('/auth/logout') + } catch { + // Ignore errors + } + clearAuth() + } + + async function fetchCurrentUser() { + try { + const response = await api.get('/auth/me') + user.value = response.data.user + } catch { + clearAuth() + } + } + + async function refreshAccessToken() { + if (!refreshToken.value) { + throw new Error('No refresh token') + } + + const response = await api.post('/auth/refresh', { + refreshToken: refreshToken.value + }) + + accessToken.value = response.data.accessToken + localStorage.setItem('accessToken', response.data.accessToken) + + return response.data.accessToken + } + + return { + user, + accessToken, + refreshToken, + orgSlug, + isAuthenticated, + hasStoredToken, + isChef, + isDisponent, + isMitarbeiter, + canManageUsers, + canManageOrders, + fullName, + login, + register, + logout, + fetchCurrentUser, + refreshAccessToken, + setOrgSlug + } +}) diff --git a/src/views/AvailabilityView.vue b/src/views/AvailabilityView.vue new file mode 100644 index 0000000..375bcbe --- /dev/null +++ b/src/views/AvailabilityView.vue @@ -0,0 +1,120 @@ + + + diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue new file mode 100644 index 0000000..7c22c09 --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,194 @@ + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..7f350e8 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,175 @@ + + + diff --git a/src/views/ModulesView.vue b/src/views/ModulesView.vue new file mode 100644 index 0000000..2885079 --- /dev/null +++ b/src/views/ModulesView.vue @@ -0,0 +1,132 @@ + + + diff --git a/src/views/OrderDetailView.vue b/src/views/OrderDetailView.vue new file mode 100644 index 0000000..86a6c51 --- /dev/null +++ b/src/views/OrderDetailView.vue @@ -0,0 +1,162 @@ + + + diff --git a/src/views/OrdersView.vue b/src/views/OrdersView.vue new file mode 100644 index 0000000..8a495e8 --- /dev/null +++ b/src/views/OrdersView.vue @@ -0,0 +1,259 @@ + + +