Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung
Features: - Backend: Node.js/Express mit MySQL/MariaDB - Frontend: Vue.js 3 mit Composition API - UTC-Zeithandling für korrekte Zeiterfassung - Timewish-basierte Überstundenberechnung - Wochenübersicht mit Urlaubs-/Krankheits-/Feiertagshandling - Bereinigtes Arbeitsende (Generell/Woche) - Überstunden-Offset für historische Daten - Fixed Layout mit scrollbarem Content - Kompakte UI mit grünem Theme
This commit is contained in:
255
frontend/src/App.vue
Normal file
255
frontend/src/App.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="navbar" v-if="authStore.isAuthenticated">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<RouterLink to="/">Stechuhr</RouterLink>
|
||||
</h1>
|
||||
<div class="nav-collapse">
|
||||
<SideMenu />
|
||||
<ul class="pull-right navbar-nav nav">
|
||||
<li class="user-info">
|
||||
<span class="user-name">{{ authStore.user?.full_name }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleLogout" class="btn-logout">Abmelden</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status-Bar unterhalb der Titelzeile -->
|
||||
<div v-if="authStore.isAuthenticated" class="status-bar">
|
||||
<div class="container status-bar-container">
|
||||
<StatusBox />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<main class="app-main" :class="{ 'no-auth': !authStore.isAuthenticated }">
|
||||
<div class="container" :class="{ 'full-width': !authStore.isAuthenticated }">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer" v-if="authStore.isAuthenticated">
|
||||
<div class="container">
|
||||
<p>© 2025 TimeClock v3 - Zeiterfassungssystem</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StatusBox from './components/StatusBox.vue'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 3rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nav-collapse {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.navbar-nav li {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.nav-link.router-link-active {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pull-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.pull-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
top: 60px; /* Höhe der Navbar */
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.status-bar-container {
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: calc(100vh - 100px);
|
||||
padding: 2rem 0;
|
||||
margin-top: 20px; /* Navbar + StatusBox Höhe */
|
||||
margin-bottom: 80px; /* Footer Höhe */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-main.no-auth {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.container.full-width {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: linear-gradient(135deg, #28a745, #20c997);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin: 5px 10px;
|
||||
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-logout::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: linear-gradient(135deg, #20c997, #17a2b8);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-logout:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-logout:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #f9f9f9;
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
border-top: 1px solid #ddd;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
158
frontend/src/assets/main.css
Normal file
158
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,158 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #ccc;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #5bc0de;
|
||||
border-color: #46b8da;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #31b0d5;
|
||||
border-color: #269abc;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #5cb85c;
|
||||
border-color: #4cae4c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #449d44;
|
||||
border-color: #398439;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #d9534f;
|
||||
border-color: #d43f3a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c9302c;
|
||||
border-color: #ac2925;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mt-3 { margin-top: 1.5rem; }
|
||||
.mt-4 { margin-top: 2rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mb-4 { margin-bottom: 2rem; }
|
||||
|
||||
190
frontend/src/components/SideMenu.vue
Normal file
190
frontend/src/components/SideMenu.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<nav class="main-menu">
|
||||
<div
|
||||
v-for="section in visibleSections"
|
||||
:key="section.title"
|
||||
class="menu-section"
|
||||
>
|
||||
<!-- Direkter Link (z.B. Export) -->
|
||||
<RouterLink
|
||||
v-if="!section.hasDropdown"
|
||||
:to="section.to"
|
||||
class="section-title section-link"
|
||||
>
|
||||
{{ section.title }}
|
||||
</RouterLink>
|
||||
|
||||
<!-- Dropdown-Menü -->
|
||||
<template v-else>
|
||||
<button class="section-title" @click="toggleSection(section.title)">
|
||||
{{ section.title }}
|
||||
<span class="chev">{{ isSectionOpen(section.title) ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="isSectionOpen(section.title)" class="dropdown">
|
||||
<RouterLink
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="dropdown-item"
|
||||
@click="openSection = null"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Rolle: 'user' | 'admin' (Fallback: 'user')
|
||||
const role = computed(() => (auth.user?.role || 'user').toString().toLowerCase())
|
||||
|
||||
const SECTIONS_USER = [
|
||||
{
|
||||
title: 'Buchungen',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Wochenübersicht', to: '/bookings/week' },
|
||||
{ label: 'Zeitkorrekturen', to: '/bookings/timefix' },
|
||||
{ label: 'Urlaub', to: '/bookings/vacation' },
|
||||
{ label: 'Krankheit', to: '/bookings/sick' },
|
||||
{ label: 'Arbeitstage', to: '/bookings/workdays' },
|
||||
{ label: 'Kalender', to: '/calendar' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Andere Nutzer',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Liste mit Nutzernamen', to: '/users' },
|
||||
{ label: 'Berechtigungen verteilen', to: '/users/permissions' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Export',
|
||||
hasDropdown: false,
|
||||
to: '/export'
|
||||
},
|
||||
{
|
||||
title: 'Einstellungen',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Persönliches', to: '/settings/profile' },
|
||||
{ label: 'Paßwort ändern', to: '/settings/password' },
|
||||
{ label: 'Zeitwünsche', to: '/settings/timewish' },
|
||||
{ label: 'Einladen', to: '/settings/invite' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const SECTIONS_ADMIN_EXTRA = [
|
||||
{
|
||||
title: 'Verwaltung',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Feiertage', to: '/admin/holidays' },
|
||||
{ label: 'Rechte', to: '/admin/roles' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const visibleSections = computed(() => {
|
||||
return role.value === 'admin'
|
||||
? [...SECTIONS_USER, ...SECTIONS_ADMIN_EXTRA]
|
||||
: SECTIONS_USER
|
||||
})
|
||||
|
||||
// Auf-/Zuklappen je Sektion (nur eins gleichzeitig offen)
|
||||
const openSection = ref(null)
|
||||
const isSectionOpen = (title) => openSection.value === title
|
||||
const toggleSection = (title) => {
|
||||
// Wenn die Sektion bereits offen ist, schließen; sonst öffnen und andere schließen
|
||||
openSection.value = openSection.value === title ? null : title
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-menu {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #555;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.section-title:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.section-link.router-link-active {
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chev {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
padding: 8px 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-item.router-link-active {
|
||||
background-color: #e8f5e9;
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
522
frontend/src/components/StatusBox.vue
Normal file
522
frontend/src/components/StatusBox.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="status-box" v-if="isReady">
|
||||
<div class="status-actions">
|
||||
<!-- Linker Button -->
|
||||
<button
|
||||
v-if="leftButton"
|
||||
class="btn btn-small"
|
||||
:class="leftButton.class"
|
||||
@click="handleAction(leftButton.action)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ leftButton.label }}
|
||||
</button>
|
||||
|
||||
<!-- Rechter Button -->
|
||||
<button
|
||||
v-if="rightButton"
|
||||
class="btn btn-small btn-secondary"
|
||||
@click="handleAction(rightButton.action)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ rightButton.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="row" v-for="(value, key) in displayRows" :key="key" :class="{ 'heading-row': value === null }">
|
||||
<span class="label">{{ key }}{{ value === null ? '' : ':' }}</span>
|
||||
<span class="value" v-if="value !== null">{{ value || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-box loading" v-else>
|
||||
Lädt…
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
const authStore = useAuthStore()
|
||||
const stats = ref({})
|
||||
const currentState = ref(null) // 'null', 'start work', 'start pause', 'stop pause'
|
||||
const isReady = ref(false)
|
||||
const loading = ref(false)
|
||||
const workStartTime = ref(null) // Timestamp wann die Arbeit begonnen hat
|
||||
const lastPauseStartTime = ref(null) // Timestamp der aktuellen Pause
|
||||
const pauseDurations = ref([]) // Array von Pausen-Dauern in Millisekunden
|
||||
const currentlyWorkedTime = ref('—') // Berechnete Arbeitszeit
|
||||
const openTime = ref('—') // Berechnete offene Zeit
|
||||
const regularEndTime = ref('—') // Berechnetes normales Arbeitsende
|
||||
const serverWorkedTime = ref(null) // Vom Server berechnete Zeit
|
||||
const serverOpenTime = ref(null) // Vom Server berechnete offene Zeit
|
||||
const serverTimestamp = ref(null) // Wann die Server-Daten berechnet wurden
|
||||
const missingBreakMinutes = ref(0) // Fehlende Pausenminuten
|
||||
let dataFetchInterval = null // Daten vom Server laden
|
||||
let displayUpdateInterval = null // Anzeige aktualisieren
|
||||
|
||||
const fetchStats = async () => {
|
||||
const data = await timeStore.fetchStats()
|
||||
stats.value = data || {}
|
||||
|
||||
// Hole vom Server berechnete Arbeitszeit, offene Zeit und Timestamp
|
||||
if (data?.currentlyWorked) {
|
||||
serverWorkedTime.value = data.currentlyWorked
|
||||
} else {
|
||||
serverWorkedTime.value = null
|
||||
}
|
||||
|
||||
if (data?.open) {
|
||||
serverOpenTime.value = data.open
|
||||
} else {
|
||||
serverOpenTime.value = null
|
||||
}
|
||||
|
||||
if (data?.timestamp) {
|
||||
serverTimestamp.value = new Date(data.timestamp).getTime()
|
||||
}
|
||||
|
||||
// Hole fehlende Pausenminuten
|
||||
if (data?.missingBreakMinutes !== undefined) {
|
||||
missingBreakMinutes.value = data.missingBreakMinutes
|
||||
} else {
|
||||
missingBreakMinutes.value = 0
|
||||
}
|
||||
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
const fetchCurrentState = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/current-state', {
|
||||
headers: authStore.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
currentState.value = result.state || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des aktuellen Zustands:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Lade die aktuellen Worklog-Daten (nur einmal pro Minute)
|
||||
const fetchWorklogData = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/running', {
|
||||
headers: authStore.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
|
||||
console.log('DEBUG fetchWorklogData: result =', result)
|
||||
console.log('DEBUG fetchWorklogData: currentState =', currentState.value)
|
||||
|
||||
// Das Backend gibt direkt das Entry-Objekt zurück, nicht { entry: ... }
|
||||
if (result && result.startTime && (currentState.value === 'start work' || currentState.value === 'stop pause')) {
|
||||
// Arbeit läuft
|
||||
workStartTime.value = new Date(result.startTime).getTime()
|
||||
pauseDurations.value = result.pauses || []
|
||||
lastPauseStartTime.value = null
|
||||
console.log('DEBUG: Arbeit läuft, startTime:', result.startTime, 'pauses:', pauseDurations.value.length)
|
||||
} else if (result && result.startTime && currentState.value === 'start pause') {
|
||||
// In Pause
|
||||
workStartTime.value = new Date(result.startTime).getTime()
|
||||
pauseDurations.value = result.pauses || []
|
||||
// Hole letzten Pause-Start
|
||||
if (result.currentPauseStart) {
|
||||
lastPauseStartTime.value = new Date(result.currentPauseStart).getTime()
|
||||
}
|
||||
console.log('DEBUG: In Pause, startTime:', result.startTime, 'currentPauseStart:', result.currentPauseStart)
|
||||
} else {
|
||||
// Nicht am Arbeiten
|
||||
workStartTime.value = null
|
||||
lastPauseStartTime.value = null
|
||||
pauseDurations.value = []
|
||||
console.log('DEBUG: Nicht am Arbeiten')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Worklog-Daten:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne die aktuell gearbeitete Zeit (2x pro Sekunde)
|
||||
const updateCurrentlyWorkedTime = () => {
|
||||
// Wenn nicht am Arbeiten
|
||||
if (currentState.value === null || currentState.value === 'stop work') {
|
||||
currentlyWorkedTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn wir einen Server-Wert haben, nutze diesen als Basis
|
||||
if (serverWorkedTime.value && serverTimestamp.value) {
|
||||
// Parse Server-Zeit (HH:MM:SS)
|
||||
const parts = serverWorkedTime.value.split(':')
|
||||
const serverSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2])
|
||||
|
||||
// Berechne vergangene Zeit seit Server-Timestamp
|
||||
const now = Date.now()
|
||||
const elapsedMs = now - serverTimestamp.value
|
||||
let elapsedSeconds = Math.floor(elapsedMs / 1000)
|
||||
|
||||
// Wenn in Pause, zähle die Zeit nicht hoch
|
||||
if (currentState.value === 'start pause') {
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
|
||||
// Addiere vergangene Zeit
|
||||
const totalSeconds = serverSeconds + elapsedSeconds
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
currentlyWorkedTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
} else {
|
||||
// Fallback: Wenn kein Server-Wert, aber workStartTime vorhanden
|
||||
if (!workStartTime.value) {
|
||||
currentlyWorkedTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let totalWorkedMs = now - workStartTime.value
|
||||
|
||||
// Ziehe abgeschlossene Pausen ab
|
||||
const totalPauseMs = pauseDurations.value.reduce((sum, duration) => sum + duration, 0)
|
||||
totalWorkedMs -= totalPauseMs
|
||||
|
||||
// Wenn aktuell in Pause, ziehe die laufende Pause ab
|
||||
if (currentState.value === 'start pause' && lastPauseStartTime.value) {
|
||||
const currentPauseMs = now - lastPauseStartTime.value
|
||||
totalWorkedMs -= currentPauseMs
|
||||
}
|
||||
|
||||
// Formatiere als HH:MM:SS
|
||||
const totalSeconds = Math.floor(totalWorkedMs / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
currentlyWorkedTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne die offene Zeit (2x pro Sekunde)
|
||||
const updateOpenTime = () => {
|
||||
// Wenn nicht am Arbeiten
|
||||
if (currentState.value === null || currentState.value === 'stop work') {
|
||||
openTime.value = '—'
|
||||
regularEndTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn wir einen Server-Wert haben, nutze diesen als Basis
|
||||
if (serverOpenTime.value && serverTimestamp.value) {
|
||||
// Parse Server-Zeit (HH:MM:SS)
|
||||
const parts = serverOpenTime.value.split(':')
|
||||
const serverSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2])
|
||||
|
||||
// Berechne vergangene Zeit seit Server-Timestamp
|
||||
const now = Date.now()
|
||||
const elapsedMs = now - serverTimestamp.value
|
||||
let elapsedSeconds = Math.floor(elapsedMs / 1000)
|
||||
|
||||
// Wenn in Pause, zähle die Zeit nicht runter (bleibt gleich)
|
||||
if (currentState.value === 'start pause') {
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
|
||||
// Subtrahiere vergangene Zeit (Offen wird weniger)
|
||||
const remainingSeconds = serverSeconds - elapsedSeconds
|
||||
|
||||
if (remainingSeconds > 0) {
|
||||
const hours = Math.floor(remainingSeconds / 3600)
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
|
||||
openTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
// Berechne "Normales Arbeitsende" = Jetzt + Offen + fehlende Pausen
|
||||
const totalRemainingSeconds = remainingSeconds + (missingBreakMinutes.value * 60)
|
||||
const endTimestamp = now + (totalRemainingSeconds * 1000)
|
||||
const endDate = new Date(endTimestamp)
|
||||
const endHours = endDate.getHours()
|
||||
const endMinutes = endDate.getMinutes()
|
||||
const endSeconds = endDate.getSeconds()
|
||||
|
||||
// Zeige auch fehlende Pausen an (falls vorhanden)
|
||||
if (missingBreakMinutes.value > 0) {
|
||||
regularEndTime.value = `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}:${endSeconds.toString().padStart(2, '0')} (+${missingBreakMinutes.value}min Pause)`
|
||||
} else {
|
||||
regularEndTime.value = `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}:${endSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
} else {
|
||||
openTime.value = 'Arbeitsende erreicht'
|
||||
|
||||
// Auch wenn Arbeitsende erreicht ist, können noch Pausen fehlen
|
||||
if (missingBreakMinutes.value > 0) {
|
||||
regularEndTime.value = `Erreicht (+${missingBreakMinutes.value}min Pause)`
|
||||
} else {
|
||||
regularEndTime.value = 'Erreicht'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openTime.value = '—'
|
||||
regularEndTime.value = '—'
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/clock', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authStore.getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
alert(error.error || 'Fehler beim Stempeln')
|
||||
return
|
||||
}
|
||||
|
||||
// Aktualisiere Status und Worklog-Daten sofort
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
|
||||
// Event auslösen für andere Komponenten (z.B. WeekOverview)
|
||||
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stempeln:', error)
|
||||
alert('Fehler beim Stempeln')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Button-Konfiguration basierend auf dem aktuellen Zustand
|
||||
const leftButton = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case null:
|
||||
case 'stop work':
|
||||
return { label: 'Arbeit beginnen', action: 'start work', class: 'btn-success' }
|
||||
case 'start work':
|
||||
case 'stop pause':
|
||||
return { label: 'Arbeit beenden', action: 'stop work', class: 'btn-danger' }
|
||||
case 'start pause':
|
||||
return null // Nicht sichtbar
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const rightButton = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case null:
|
||||
case 'stop work':
|
||||
return null // Nicht sichtbar
|
||||
case 'start work':
|
||||
case 'stop pause':
|
||||
return { label: 'Pause beginnen', action: 'start pause' }
|
||||
case 'start pause':
|
||||
return { label: 'Pause beenden', action: 'stop pause' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Initiales Laden
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
|
||||
// Server-Daten alle 60 Sekunden neu laden
|
||||
dataFetchInterval = setInterval(async () => {
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
}, 60000)
|
||||
|
||||
// Anzeige 2x pro Sekunde aktualisieren (nur Berechnung, keine Server-Requests)
|
||||
displayUpdateInterval = setInterval(() => {
|
||||
updateCurrentlyWorkedTime()
|
||||
updateOpenTime()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (dataFetchInterval) clearInterval(dataFetchInterval)
|
||||
if (displayUpdateInterval) clearInterval(displayUpdateInterval)
|
||||
})
|
||||
|
||||
const displayRows = computed(() => {
|
||||
const rows = {
|
||||
'Derzeit gearbeitet': currentlyWorkedTime.value, // Verwende berechneten Wert
|
||||
'Offen': openTime.value, // Verwende berechneten Wert
|
||||
'Normales Arbeitsende': regularEndTime.value // Verwende berechneten Wert
|
||||
}
|
||||
|
||||
// Füge andere Stats hinzu
|
||||
const map = [
|
||||
['Überstunden (Woche)', 'overtime'],
|
||||
['Überstunden (Gesamt)', 'totalOvertime'],
|
||||
// ['Überstunden (Alt-Style)', 'totalOvertimeOldStyle'], // DEBUG: Versteckt, da getWeekOverview nicht korrekte Zeiten liefert
|
||||
['Wochenarbeitszeit', 'weekWorktime'],
|
||||
['Arbeitsfreie Stunden', 'nonWorkingHours'],
|
||||
['Offen für Woche', 'openForWeek'],
|
||||
['Bereinigtes Arbeitsende (heute)', null], // Nur Überschrift, kein Wert
|
||||
['- Generell', 'adjustedEndTodayGeneral'],
|
||||
['- Woche', 'adjustedEndTodayWeek']
|
||||
]
|
||||
|
||||
for (const [label, key] of map) {
|
||||
// Spezialbehandlung für Überschrift ohne Wert
|
||||
if (key === null) {
|
||||
rows[label] = null // Überschrift ohne Wert (wird im Template speziell behandelt)
|
||||
continue
|
||||
}
|
||||
|
||||
const val = stats.value?.[key]
|
||||
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
rows[label] = val
|
||||
} else {
|
||||
rows[label] = '—'
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-box {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
||||
position: fixed;
|
||||
top: 58px;
|
||||
right: 24px;
|
||||
max-width: 560px;
|
||||
min-width: 280px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.status-box.loading {
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #f8f8f8;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
color: #155724;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #c3e6cb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
color: #721c24;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #f5c6cb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e7f1ff;
|
||||
border-color: #b8daff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #b8daff;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
font-size: 12px;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.row.heading-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row.heading-row .label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
25
frontend/src/main.js
Normal file
25
frontend/src/main.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Session beim App-Start wiederherstellen
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Versuche Token aus localStorage zu laden
|
||||
if (authStore.loadToken()) {
|
||||
authStore.fetchCurrentUser().catch(err => {
|
||||
console.error('Session-Wiederherstellung fehlgeschlagen:', err)
|
||||
})
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
104
frontend/src/router/index.js
Normal file
104
frontend/src/router/index.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
// Views
|
||||
import Entries from '../views/Entries.vue'
|
||||
import Stats from '../views/Stats.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Register from '../views/Register.vue'
|
||||
import PasswordForgot from '../views/PasswordForgot.vue'
|
||||
import PasswordReset from '../views/PasswordReset.vue'
|
||||
import OAuthCallback from '../views/OAuthCallback.vue'
|
||||
import WeekOverview from '../views/WeekOverview.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
// Auth-Routes (öffentlich)
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/password-forgot',
|
||||
name: 'password-forgot',
|
||||
component: PasswordForgot,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/password-reset',
|
||||
name: 'password-reset',
|
||||
component: PasswordReset,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/oauth-callback',
|
||||
name: 'oauth-callback',
|
||||
component: OAuthCallback
|
||||
},
|
||||
|
||||
// Geschützte Routes
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/bookings/week'
|
||||
},
|
||||
{
|
||||
path: '/bookings/week',
|
||||
name: 'week-overview',
|
||||
component: WeekOverview,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/entries',
|
||||
name: 'entries',
|
||||
component: Entries,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
component: Stats,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Navigation Guards
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Session-Wiederherstellung beim ersten Laden
|
||||
if (!authStore.isAuthenticated && authStore.loadToken()) {
|
||||
try {
|
||||
await authStore.fetchCurrentUser()
|
||||
} catch (error) {
|
||||
console.error('Session-Wiederherstellung fehlgeschlagen:', error)
|
||||
authStore.clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const requiresGuest = to.matched.some(record => record.meta.requiresGuest)
|
||||
|
||||
if (requiresAuth && !authStore.isAuthenticated) {
|
||||
// Geschützte Route aber nicht eingeloggt -> Login
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (requiresGuest && authStore.isAuthenticated) {
|
||||
// Guest-Route aber bereits eingeloggt -> Wochenübersicht
|
||||
next({ name: 'week-overview' })
|
||||
} else {
|
||||
// Alles OK
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
338
frontend/src/stores/authStore.js
Normal file
338
frontend/src/stores/authStore.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const API_URL = 'http://localhost:3010/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
/**
|
||||
* Token in localStorage speichern
|
||||
*/
|
||||
const saveToken = (newToken) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('timeclock_token', newToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Token aus localStorage laden
|
||||
*/
|
||||
const loadToken = () => {
|
||||
const savedToken = localStorage.getItem('timeclock_token')
|
||||
if (savedToken) {
|
||||
token.value = savedToken
|
||||
return savedToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Token und Benutzerdaten löschen
|
||||
*/
|
||||
const clearAuth = () => {
|
||||
token.value = null
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
localStorage.removeItem('timeclock_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrierung
|
||||
*/
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registrierung fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Registrierungsfehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login
|
||||
*/
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Login fehlgeschlagen')
|
||||
}
|
||||
|
||||
// Token speichern
|
||||
saveToken(data.token)
|
||||
|
||||
// Benutzerdaten setzen
|
||||
user.value = data.user
|
||||
isAuthenticated.value = true
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error)
|
||||
clearAuth()
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (token.value) {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout-Fehler:', error)
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Benutzer laden (für Session-Wiederherstellung)
|
||||
*/
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const savedToken = loadToken()
|
||||
|
||||
if (!savedToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${savedToken}`
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Token ungültig - ausloggen
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
|
||||
// Session wiederherstellen
|
||||
user.value = data.user
|
||||
isAuthenticated.value = true
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Benutzers:', error)
|
||||
clearAuth()
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
*/
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
const savedToken = loadToken()
|
||||
|
||||
if (!savedToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/validate`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${savedToken}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Token-Validierungsfehler:', error)
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
*/
|
||||
const requestPasswordReset = async (email) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/request-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Anfrage fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Passwort-Reset-Anfrage-Fehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
*/
|
||||
const resetPassword = async (resetToken, password) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: resetToken,
|
||||
password
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Passwort-Reset fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Passwort-Reset-Fehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (eingeloggter Benutzer)
|
||||
*/
|
||||
const changePassword = async (oldPassword, newPassword) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token.value}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPassword,
|
||||
newPassword
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Passwort-Änderung fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Passwort-Änderungs-Fehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-Header mit Authorization zurückgeben
|
||||
*/
|
||||
const getAuthHeaders = () => {
|
||||
const savedToken = token.value || loadToken()
|
||||
|
||||
if (savedToken) {
|
||||
return {
|
||||
'Authorization': `Bearer ${savedToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
|
||||
// Actions
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
validateToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
changePassword,
|
||||
getAuthHeaders,
|
||||
loadToken,
|
||||
saveToken,
|
||||
clearAuth
|
||||
}
|
||||
})
|
||||
|
||||
125
frontend/src/stores/timeStore.js
Normal file
125
frontend/src/stores/timeStore.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
const API_URL = 'http://localhost:3010/api'
|
||||
|
||||
export const useTimeStore = defineStore('time', () => {
|
||||
const entries = ref([])
|
||||
const currentEntry = ref(null)
|
||||
|
||||
/**
|
||||
* API-Request mit Auth-Header
|
||||
*/
|
||||
const fetchWithAuth = async (url, options = {}) => {
|
||||
const authStore = useAuthStore()
|
||||
const headers = authStore.getAuthHeaders()
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
|
||||
// Bei 401 Unauthorized -> Logout
|
||||
if (response.status === 401) {
|
||||
authStore.clearAuth()
|
||||
window.location.href = '/login'
|
||||
throw new Error('Session abgelaufen')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const fetchEntries = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries`)
|
||||
const data = await response.json()
|
||||
entries.value = data
|
||||
|
||||
// Finde laufenden Eintrag
|
||||
currentEntry.value = data.find(entry => entry.isRunning) || null
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Einträge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startTimer = async (entryData) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(entryData)
|
||||
})
|
||||
|
||||
const newEntry = await response.json()
|
||||
currentEntry.value = newEntry
|
||||
entries.value.unshift(newEntry)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Starten des Timers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stopTimer = async () => {
|
||||
if (!currentEntry.value) return
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries/${currentEntry.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endTime: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
const updatedEntry = await response.json()
|
||||
|
||||
// Aktualisiere Eintrag in Liste
|
||||
const index = entries.value.findIndex(e => e.id === updatedEntry.id)
|
||||
if (index !== -1) {
|
||||
entries.value[index] = updatedEntry
|
||||
}
|
||||
|
||||
currentEntry.value = null
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stoppen des Timers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async (id) => {
|
||||
try {
|
||||
await fetchWithAuth(`${API_URL}/time-entries/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
entries.value = entries.value.filter(entry => entry.id !== id)
|
||||
|
||||
if (currentEntry.value && currentEntry.value.id === id) {
|
||||
currentEntry.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Eintrags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries/stats/summary`)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Statistiken:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
currentEntry,
|
||||
fetchEntries,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
deleteEntry,
|
||||
fetchStats
|
||||
}
|
||||
})
|
||||
|
||||
265
frontend/src/views/Dashboard.vue
Normal file
265
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h2 class="page-title">Dashboard</h2>
|
||||
|
||||
<!-- Aktiver Timer -->
|
||||
<div class="card timer-card">
|
||||
<div class="timer-header">
|
||||
<h3>Zeiterfassung</h3>
|
||||
<div class="timer-display">{{ formattedTime }}</div>
|
||||
</div>
|
||||
|
||||
<form v-if="!isRunning" @submit.prevent="startTimer" class="timer-form">
|
||||
<div class="form-group">
|
||||
<label for="project">Projekt</label>
|
||||
<input
|
||||
v-model="newEntry.project"
|
||||
type="text"
|
||||
id="project"
|
||||
class="input"
|
||||
placeholder="z.B. Projektname"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<input
|
||||
v-model="newEntry.description"
|
||||
type="text"
|
||||
id="description"
|
||||
class="input"
|
||||
placeholder="Was arbeitest du gerade?"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-large">
|
||||
▶️ Starten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="running-timer">
|
||||
<div class="running-info">
|
||||
<div class="info-item">
|
||||
<strong>Projekt:</strong> {{ currentEntry.project }}
|
||||
</div>
|
||||
<div class="info-item" v-if="currentEntry.description">
|
||||
<strong>Beschreibung:</strong> {{ currentEntry.description }}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Gestartet:</strong> {{ formatDateTime(currentEntry.startTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="stopTimer" class="btn btn-danger btn-large">
|
||||
⏹️ Stoppen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Einträge -->
|
||||
<div class="recent-entries">
|
||||
<h3>Letzte Einträge</h3>
|
||||
<div v-if="recentEntries.length === 0" class="empty-state">
|
||||
<p>Noch keine Einträge vorhanden. Starte deinen ersten Timer!</p>
|
||||
</div>
|
||||
<div v-else class="entries-list">
|
||||
<div v-for="entry in recentEntries" :key="entry.id" class="entry-card card">
|
||||
<div class="entry-header">
|
||||
<span class="entry-project">{{ entry.project }}</span>
|
||||
<span class="entry-duration">{{ formatDuration(entry.duration) }}</span>
|
||||
</div>
|
||||
<div class="entry-description">{{ entry.description || 'Keine Beschreibung' }}</div>
|
||||
<div class="entry-time">
|
||||
{{ formatDateTime(entry.startTime) }} - {{ formatDateTime(entry.endTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
|
||||
const newEntry = ref({
|
||||
project: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const isRunning = computed(() => timeStore.currentEntry !== null)
|
||||
const currentEntry = computed(() => timeStore.currentEntry)
|
||||
const recentEntries = computed(() => timeStore.entries.slice(0, 5))
|
||||
|
||||
const currentTime = ref(0)
|
||||
let intervalId = null
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!isRunning.value) return '00:00:00'
|
||||
return formatDuration(currentTime.value)
|
||||
})
|
||||
|
||||
const startTimer = async () => {
|
||||
await timeStore.startTimer(newEntry.value)
|
||||
newEntry.value = { project: '', description: '' }
|
||||
}
|
||||
|
||||
const stopTimer = async () => {
|
||||
await timeStore.stopTimer()
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '00:00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
if (isRunning.value && currentEntry.value) {
|
||||
const start = new Date(currentEntry.value.startTime)
|
||||
const now = new Date()
|
||||
currentTime.value = Math.floor((now - start) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await timeStore.fetchEntries()
|
||||
intervalId = setInterval(updateCurrentTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.timer-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.timer-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timer-header h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #000;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timer-form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.running-timer {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.running-info {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recent-entries h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.entries-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.entry-project {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.entry-duration {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #5cb85c;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.entry-description {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
209
frontend/src/views/Entries.vue
Normal file
209
frontend/src/views/Entries.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="entries">
|
||||
<h2 class="page-title">Alle Zeiteinträge</h2>
|
||||
|
||||
<div v-if="timeStore.entries.length === 0" class="empty-state card">
|
||||
<h3>Keine Einträge vorhanden</h3>
|
||||
<p>Starte deinen ersten Timer im Dashboard!</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="entries-container">
|
||||
<div class="entries-header">
|
||||
<p class="entries-count">{{ timeStore.entries.length }} Einträge</p>
|
||||
</div>
|
||||
|
||||
<div class="entries-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Projekt</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Startzeit</th>
|
||||
<th>Endzeit</th>
|
||||
<th>Dauer</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in timeStore.entries" :key="entry.id">
|
||||
<td class="project-cell">{{ entry.project }}</td>
|
||||
<td>{{ entry.description || '-' }}</td>
|
||||
<td>{{ formatDateTime(entry.startTime) }}</td>
|
||||
<td>{{ entry.endTime ? formatDateTime(entry.endTime) : '-' }}</td>
|
||||
<td class="duration-cell">{{ formatDuration(entry.duration) }}</td>
|
||||
<td>
|
||||
<span :class="['status-badge', entry.isRunning ? 'status-running' : 'status-completed']">
|
||||
{{ entry.isRunning ? '🔴 Läuft' : '✅ Beendet' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="deleteEntry(entry.id)"
|
||||
class="btn-icon btn-delete"
|
||||
title="Löschen"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '-'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEntry = async (id) => {
|
||||
if (confirm('Möchtest du diesen Eintrag wirklich löschen?')) {
|
||||
await timeStore.deleteEntry(id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await timeStore.fetchEntries()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.entries-container {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.entries-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.entries-count {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #d4d4d4;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.project-cell {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.duration-cell {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background-color: #fcf8e3;
|
||||
color: #8a6d3b;
|
||||
border: 1px solid #faebcc;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #dff0d8;
|
||||
color: #3c763d;
|
||||
border: 1px solid #d6e9c6;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
363
frontend/src/views/Login.vue
Normal file
363
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Einloggen</h2>
|
||||
<p></p>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input
|
||||
v-model="loginForm.email"
|
||||
type="text"
|
||||
id="email"
|
||||
size="10"
|
||||
class="form-input"
|
||||
title="Ihre E-Mail-Adresse eingeben"
|
||||
/>
|
||||
<span class="info">Ihre E-Mail-Adresse eingeben</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
id="password"
|
||||
size="10"
|
||||
class="form-input"
|
||||
title="Ihr Passwort eingeben"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
<span class="info">Ihr Passwort eingeben</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="remember">Login merken</label>
|
||||
<input
|
||||
v-model="rememberMe"
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
/>
|
||||
<span class="info"></span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="action">Aktion</label>
|
||||
<select id="action" v-model="loginAction" class="form-input">
|
||||
<option value="0">Keine Aktion</option>
|
||||
<option value="1">Arbeit beginnen</option>
|
||||
<option value="2">Pause beginnen</option>
|
||||
<option value="3">Pause beenden</option>
|
||||
<option value="4">Feierabend</option>
|
||||
</select>
|
||||
<span class="info">Wird beim Einloggen ausgeführt</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird eingeloggt...' : 'Einloggen' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-divider">
|
||||
<span>oder</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleGoogleLogin"
|
||||
class="btn btn-google"
|
||||
>
|
||||
<svg class="google-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="20px" height="20px">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/>
|
||||
</svg>
|
||||
Mit Google anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link to="/password-forgot" class="link">Passwort vergessen</router-link>
|
||||
|
|
||||
<router-link to="/register" class="link">Registrieren</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginForm = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rememberMe = ref(false)
|
||||
const loginAction = ref('0')
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
|
||||
await authStore.login({
|
||||
email: loginForm.value.email,
|
||||
password: loginForm.value.password
|
||||
})
|
||||
|
||||
// Nach erfolgreichem Login zum Dashboard
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Login fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.'
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect zu Google OAuth
|
||||
window.location.href = 'http://localhost:3010/api/auth/google'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input[type="text"],
|
||||
.auth-form-group input[type="password"],
|
||||
.auth-form-group input[type="email"],
|
||||
.auth-form-group select {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus,
|
||||
.auth-form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.buttons .btn-primary {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5bc0de;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
color: #31b0d5;
|
||||
}
|
||||
|
||||
.oauth-divider {
|
||||
text-align: center;
|
||||
margin: 1.5em 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.oauth-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.oauth-divider span {
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: 0 1em;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
color: #444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-google:hover {
|
||||
background-color: #f8f8f8;
|
||||
border-color: #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/views/OAuthCallback.vue
Normal file
97
frontend/src/views/OAuthCallback.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="oauth-callback">
|
||||
<div class="loading-container">
|
||||
<h2>{{ status }}</h2>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const status = ref('Authentifizierung läuft...')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.query.token
|
||||
const error = route.query.error
|
||||
|
||||
if (error) {
|
||||
status.value = 'OAuth-Login fehlgeschlagen'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Token speichern
|
||||
authStore.saveToken(token)
|
||||
|
||||
// Benutzer-Daten laden
|
||||
await authStore.fetchCurrentUser()
|
||||
|
||||
status.value = 'Login erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
status.value = 'Fehler beim Login'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
status.value = 'Kein Token erhalten'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oauth-callback {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-container h2 {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #5bc0de;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
309
frontend/src/views/PasswordForgot.vue
Normal file
309
frontend/src/views/PasswordForgot.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Passwort vergessen</h2>
|
||||
|
||||
<form @submit.prevent="handleResetRequest" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div v-if="resetLink" class="reset-link-container">
|
||||
<p><strong>Bitte kopieren Sie diesen Link oder klicken Sie darauf:</strong></p>
|
||||
<a :href="resetLink" class="reset-link">{{ resetLink }}</a>
|
||||
<button @click="copyResetLink" class="btn btn-secondary" style="margin-top: 10px;">
|
||||
Link kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
:disabled="!!success"
|
||||
required
|
||||
/>
|
||||
<span class="info">Ihre registrierte E-Mail-Adresse</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="!success"
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird gesendet...' : 'Reset-Link senden' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link to="/login" class="link">Zurück zum Login</router-link>
|
||||
|
|
||||
<router-link to="/register" class="link">Registrieren</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const email = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const resetLink = ref('')
|
||||
|
||||
const handleResetRequest = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
resetLink.value = ''
|
||||
|
||||
const result = await authStore.requestPasswordReset(email.value)
|
||||
|
||||
if (result.resetToken) {
|
||||
// Development: Zeige Reset-Link direkt an
|
||||
const baseUrl = window.location.origin
|
||||
resetLink.value = `${baseUrl}/password-reset?token=${result.resetToken}`
|
||||
success.value = 'Reset-Link wurde generiert (Development-Modus):'
|
||||
} else {
|
||||
success.value = 'Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet.'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Fehler beim Senden des Reset-Links'
|
||||
}
|
||||
}
|
||||
|
||||
const copyResetLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(resetLink.value)
|
||||
alert('Link in Zwischenablage kopiert!')
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Kopieren:', err)
|
||||
// Fallback für ältere Browser
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = resetLink.value
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('Link in Zwischenablage kopiert!')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
color: #3c763d;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reset-link-container {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 1em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reset-link-container p {
|
||||
margin: 0 0 0.5em 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.reset-link {
|
||||
display: block;
|
||||
color: #5bc0de;
|
||||
word-break: break-all;
|
||||
padding: 0.5em;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.reset-link:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5bc0de;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
color: #31b0d5;
|
||||
}
|
||||
</style>
|
||||
288
frontend/src/views/PasswordReset.vue
Normal file
288
frontend/src/views/PasswordReset.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Neues Passwort setzen</h2>
|
||||
|
||||
<form @submit.prevent="handleReset" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="!!success"
|
||||
/>
|
||||
<span class="info">Mindestens 6 Zeichen</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
type="password"
|
||||
id="password_confirm"
|
||||
class="form-input"
|
||||
required
|
||||
:disabled="!!success"
|
||||
/>
|
||||
<span class="info">Passwort wiederholen</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="!success"
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird gespeichert...' : 'Passwort zurücksetzen' }}
|
||||
</button>
|
||||
<router-link
|
||||
v-else
|
||||
to="/login"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Zum Login
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link to="/login" class="link">Zurück zum Login</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const resetToken = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
resetToken.value = route.query.token || ''
|
||||
|
||||
if (!resetToken.value) {
|
||||
error.value = 'Kein Reset-Token vorhanden. Bitte fordern Sie einen neuen Reset-Link an.'
|
||||
}
|
||||
})
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = 'Passwörter stimmen nicht überein'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value.length < 6) {
|
||||
error.value = 'Passwort muss mindestens 6 Zeichen lang sein'
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.resetPassword(resetToken.value, password.value)
|
||||
success.value = 'Passwort wurde erfolgreich zurückgesetzt! Sie können sich jetzt einloggen.'
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Fehler beim Zurücksetzen des Passworts'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
color: #3c763d;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
min-width: 140px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5bc0de;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
color: #31b0d5;
|
||||
}
|
||||
</style>
|
||||
298
frontend/src/views/Register.vue
Normal file
298
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Registrieren</h2>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="full_name">Vollständiger Name</label>
|
||||
<input
|
||||
v-model="registerForm.full_name"
|
||||
type="text"
|
||||
id="full_name"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<span class="info">Ihr vollständiger Name</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input
|
||||
v-model="registerForm.email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<span class="info">Ihre E-Mail-Adresse</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
required
|
||||
minlength="6"
|
||||
/>
|
||||
<span class="info">Mindestens 6 Zeichen</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input
|
||||
v-model="registerForm.password_confirm"
|
||||
type="password"
|
||||
id="password_confirm"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<span class="info">Passwort wiederholen</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird registriert...' : 'Registrieren' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
Bereits registriert?
|
||||
<router-link to="/login" class="link">Jetzt einloggen</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const registerForm = ref({
|
||||
full_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
if (registerForm.value.password !== registerForm.value.password_confirm) {
|
||||
error.value = 'Passwörter stimmen nicht überein'
|
||||
return
|
||||
}
|
||||
|
||||
if (registerForm.value.password.length < 6) {
|
||||
error.value = 'Passwort muss mindestens 6 Zeichen lang sein'
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.register({
|
||||
full_name: registerForm.value.full_name,
|
||||
email: registerForm.value.email,
|
||||
password: registerForm.value.password
|
||||
})
|
||||
|
||||
success.value = 'Registrierung erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Registrierung fehlgeschlagen'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input,
|
||||
.auth-form-group select {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus,
|
||||
.auth-form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
color: #3c763d;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1em;
|
||||
text-align: left;
|
||||
padding-left: 7em;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #337ab7;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
color: #23527c;
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/views/Stats.vue
Normal file
204
frontend/src/views/Stats.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="stats">
|
||||
<h2 class="page-title">Statistiken</h2>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Gesamt-Statistiken -->
|
||||
<div class="stats-grid grid grid-2">
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value">{{ stats.totalEntries || 0 }}</div>
|
||||
<div class="stat-label">Gesamt Einträge</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-value">{{ stats.completedEntries || 0 }}</div>
|
||||
<div class="stat-label">Abgeschlossene Einträge</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">🔴</div>
|
||||
<div class="stat-value">{{ stats.runningEntries || 0 }}</div>
|
||||
<div class="stat-label">Laufende Einträge</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">⏱️</div>
|
||||
<div class="stat-value">{{ stats.totalHours || '0.00' }} h</div>
|
||||
<div class="stat-label">Gesamtstunden</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projekt-Statistiken -->
|
||||
<div class="project-stats">
|
||||
<h3>Statistiken nach Projekt</h3>
|
||||
<div v-if="Object.keys(stats.projectStats || {}).length === 0" class="empty-state card">
|
||||
<p>Noch keine Projekt-Statistiken verfügbar</p>
|
||||
</div>
|
||||
<div v-else class="project-list">
|
||||
<div
|
||||
v-for="(data, project) in stats.projectStats"
|
||||
:key="project"
|
||||
class="project-card card"
|
||||
>
|
||||
<div class="project-header">
|
||||
<h4>{{ project }}</h4>
|
||||
<span class="project-count">{{ data.count }} Einträge</span>
|
||||
</div>
|
||||
<div class="project-duration">
|
||||
<span class="duration-label">Gesamtdauer:</span>
|
||||
<span class="duration-value">{{ formatDuration(data.duration) }}</span>
|
||||
</div>
|
||||
<div class="project-hours">
|
||||
{{ (data.duration / 3600).toFixed(2) }} Stunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
const stats = ref({})
|
||||
const loading = ref(true)
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '00:00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
stats.value = await timeStore.fetchStats()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.project-stats h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.project-header h4 {
|
||||
margin: 0;
|
||||
color: #000;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
background-color: #f0f0f0;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
border: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.project-duration {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #5cb85c;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.project-hours {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
697
frontend/src/views/WeekOverview.vue
Normal file
697
frontend/src/views/WeekOverview.vue
Normal file
@@ -0,0 +1,697 @@
|
||||
<template>
|
||||
<div class="week-overview">
|
||||
<div class="card">
|
||||
<h2>Wochenübersicht</h2>
|
||||
<p class="subtitle">Übersicht Ihrer Arbeitszeiten für die aktuelle Woche</p>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
Lade Daten...
|
||||
</div>
|
||||
|
||||
<div v-else class="week-content">
|
||||
<div class="week-header">
|
||||
<button @click="previousWeek" class="btn btn-secondary">← Vorherige Woche</button>
|
||||
<h3>{{ weekRange }}</h3>
|
||||
<button @click="nextWeek" class="btn btn-secondary">Nächste Woche →</button>
|
||||
</div>
|
||||
|
||||
<table class="week-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Datum</th>
|
||||
<th>Zeiten</th>
|
||||
<th>Arbeitszeit</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in weekDays" :key="day.date" :class="{ 'current-day': day.isToday }">
|
||||
<td class="day-name">{{ day.name }}</td>
|
||||
<td>{{ day.date }}</td>
|
||||
<td class="times-cell">
|
||||
<!-- Mehrere Arbeitsblöcke unterstützen -->
|
||||
<template v-if="day.workBlocks && day.workBlocks.length > 0">
|
||||
<div v-for="(block, blockIndex) in day.workBlocks" :key="blockIndex" class="work-block">
|
||||
<div class="time-entry">
|
||||
<strong v-if="day.workBlocks.length > 1">Arbeitszeit {{ blockIndex + 1 }}: </strong>
|
||||
<strong v-else>Arbeitszeit: </strong>
|
||||
<template v-if="block.workTimeFixed && block.workTimeOriginal">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${block.workTimeOriginal}`"
|
||||
>
|
||||
{{ block.workTime }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ block.workTime }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-for="(pause, pIndex) in block.pauses" :key="pIndex" class="time-entry pause-entry">
|
||||
<template v-if="block.pauses.length > 1">Pause {{ pIndex + 1 }}: </template>
|
||||
<template v-else>Pause: </template>
|
||||
<template v-if="pause.fixed && pause.original">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${pause.original}`"
|
||||
>
|
||||
{{ pause.time || pause }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ pause.time || pause }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Fallback für alte Datenstruktur -->
|
||||
<template v-else-if="day.workTime">
|
||||
<div class="time-entry">
|
||||
<strong>Arbeitszeit:</strong>
|
||||
<template v-if="day.workTimeFixed && day.workTimeOriginal">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${day.workTimeOriginal}`"
|
||||
>
|
||||
{{ day.workTime }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ day.workTime }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-for="(pause, index) in day.pauses" :key="index" class="time-entry pause-entry">
|
||||
<template v-if="day.pauses.length > 1">Pause {{ index + 1 }}: </template>
|
||||
<template v-else>Pause: </template>
|
||||
<template v-if="pause.fixed && pause.original">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${pause.original}`"
|
||||
>
|
||||
{{ pause.time || pause }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ pause.time || pause }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="total-cell">
|
||||
<!-- Wenn workBlocks vorhanden sind, zeige Block-basierte Werte -->
|
||||
<template v-if="day.workBlocks && day.workBlocks.length > 0">
|
||||
<template v-for="(block, blockIndex) in day.workBlocks" :key="blockIndex">
|
||||
<div v-if="block.totalWorkTime" class="total-work-time">
|
||||
{{ block.totalWorkTime }}
|
||||
</div>
|
||||
<div v-for="(pauseTime, pIndex) in block.pauseTimes" :key="`${blockIndex}-${pIndex}`" class="total-pause-time">
|
||||
- <span
|
||||
:class="{ 'time-fixed': pauseTime.fixed }"
|
||||
:title="pauseTime.original ? `Original: ${pauseTime.original}` : ''"
|
||||
>
|
||||
{{ pauseTime.time || pauseTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="block.netWorkTime" class="net-work-time">
|
||||
= <strong>{{ block.netWorkTime }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Gesamt-Nettozeit (falls mehrere Blöcke) -->
|
||||
<div v-if="day.workBlocks.length > 1 && day.netWorkTime" class="total-net-work-time">
|
||||
<strong>Gesamt: {{ day.netWorkTime }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fallback für alte Struktur -->
|
||||
<template v-else>
|
||||
<div v-if="day.totalWorkTime" class="total-work-time">
|
||||
{{ day.totalWorkTime }}
|
||||
</div>
|
||||
<div v-for="(pauseTime, index) in day.pauseTimes" :key="index" class="total-pause-time">
|
||||
- <span
|
||||
:class="{ 'time-fixed': pauseTime.fixed }"
|
||||
:title="pauseTime.original ? `Original: ${pauseTime.original}` : ''"
|
||||
>
|
||||
{{ pauseTime.time || pauseTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="day.netWorkTime" class="net-work-time">
|
||||
= <strong>{{ day.netWorkTime }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Vacation, Sick und Holiday werden immer angezeigt -->
|
||||
<div v-if="day.holiday" class="holiday-time">
|
||||
+ {{ day.holiday.hours }}:00 Feiertag
|
||||
</div>
|
||||
<div v-if="day.vacation" class="vacation-time">
|
||||
+ {{ day.vacation.hours }}:00 Urlaub
|
||||
</div>
|
||||
<div v-if="day.sick" class="sick-time">
|
||||
8:00 ({{ day.sick.type === 'self' ? 'Krank' : day.sick.type === 'child' ? 'Kind krank' : day.sick.type === 'parents' ? 'Eltern krank' : 'Partner krank' }})
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="day.status" class="status-badge" :class="`status-${day.status}`">
|
||||
{{ day.statusText }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summary-row">
|
||||
<td colspan="3"><strong>Wochensumme</strong></td>
|
||||
<td class="total-hours"><strong>{{ weekTotal }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr v-if="weekData?.nonWorkingDays > 0" class="summary-row non-working-info">
|
||||
<td colspan="3" class="non-working-label">
|
||||
<strong>Arbeitsfreie Tage ({{ weekData.nonWorkingDays }}):</strong>
|
||||
<span v-for="(detail, index) in weekData.nonWorkingDetails" :key="index" class="non-working-detail">
|
||||
{{ formatDate(detail.date) }}: {{ detail.type }} ({{ detail.hours }}h)
|
||||
<span v-if="index < weekData.nonWorkingDetails.length - 1">, </span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="non-working-total"><strong>{{ weekData.nonWorkingTotal }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="summary-row total-all-row">
|
||||
<td colspan="3"><strong>Gesamtsumme</strong></td>
|
||||
<td class="total-all-hours"><strong>{{ weekData?.totalAll || weekTotal }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const weekData = ref(null)
|
||||
const weekOffset = ref(0)
|
||||
|
||||
const weekDays = computed(() => {
|
||||
if (!weekData.value) return []
|
||||
|
||||
return weekData.value.days.map(day => ({
|
||||
...day,
|
||||
date: formatDate(day.date),
|
||||
name: day.name
|
||||
}))
|
||||
})
|
||||
|
||||
const weekRange = computed(() => {
|
||||
if (!weekData.value) return ''
|
||||
|
||||
const start = formatDate(weekData.value.weekStart)
|
||||
const end = formatDate(weekData.value.weekEnd)
|
||||
|
||||
return `${start} - ${end}`
|
||||
})
|
||||
|
||||
const weekTotal = computed(() => {
|
||||
return weekData.value?.weekTotal || '0:00'
|
||||
})
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const loadWeekData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await fetch(`http://localhost:3010/api/week-overview?weekOffset=${weekOffset.value}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Wochenübersicht')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
weekData.value = result.data
|
||||
} else {
|
||||
throw new Error(result.error || 'Unbekannter Fehler')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Wochenübersicht:', error)
|
||||
// Fallback zu Dummy-Daten bei Fehlern
|
||||
weekData.value = getDummyData()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const previousWeek = () => {
|
||||
weekOffset.value--
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
const nextWeek = () => {
|
||||
weekOffset.value++
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
const getDummyData = () => {
|
||||
return {
|
||||
weekStart: '2025-01-13',
|
||||
weekEnd: '2025-01-19',
|
||||
weekOffset: weekOffset.value,
|
||||
weekTotal: '40:00',
|
||||
days: [
|
||||
{
|
||||
date: '2025-01-13',
|
||||
name: 'Montag',
|
||||
workTime: '08:00 - 16:30',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '8:30',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '8:00',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-14',
|
||||
name: 'Dienstag',
|
||||
workTime: '08:15 - 17:00',
|
||||
pauses: ['12:00 - 12:30', '14:00 - 14:30'],
|
||||
totalWorkTime: '8:45',
|
||||
pauseTimes: ['0:30', '0:30'],
|
||||
netWorkTime: '7:45',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-15',
|
||||
name: 'Mittwoch',
|
||||
workTime: '08:00 - 16:00',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '8:00',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '7:30',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-16',
|
||||
name: 'Donnerstag',
|
||||
workTime: '08:30 - 17:30',
|
||||
pauses: ['12:00 - 12:30', '15:00 - 15:15'],
|
||||
totalWorkTime: '9:00',
|
||||
pauseTimes: ['0:30', '0:15'],
|
||||
netWorkTime: '8:15',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-17',
|
||||
name: 'Freitag',
|
||||
workTime: '08:00 - 15:00',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '7:00',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '6:30',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-18',
|
||||
name: 'Samstag',
|
||||
workTime: null,
|
||||
pauses: [],
|
||||
totalWorkTime: null,
|
||||
pauseTimes: [],
|
||||
netWorkTime: null,
|
||||
status: 'weekend',
|
||||
statusText: 'Wochenende',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-19',
|
||||
name: 'Sonntag',
|
||||
workTime: null,
|
||||
pauses: [],
|
||||
totalWorkTime: null,
|
||||
pauseTimes: [],
|
||||
netWorkTime: null,
|
||||
status: 'weekend',
|
||||
statusText: 'Wochenende',
|
||||
isToday: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorklogUpdate = () => {
|
||||
console.log('Worklog aktualisiert, lade Wochendaten neu...')
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWeekData()
|
||||
|
||||
// Event-Listener für Worklog-Updates
|
||||
window.addEventListener('worklog-updated', handleWorklogUpdate)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('worklog-updated', handleWorklogUpdate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.week-overview {
|
||||
padding: 1rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #333;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.week-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.week-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.week-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.week-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.week-table th {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.week-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: top;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.week-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.current-day {
|
||||
background: #e8f5e9 !important;
|
||||
}
|
||||
|
||||
.current-day:hover {
|
||||
background: #d4edda !important;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.times-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.work-block {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
.work-block:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.time-entry {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pause-entry {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.time-fixed {
|
||||
color: #0066cc;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.total-cell {
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total-work-time {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.total-pause-time {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.net-work-time {
|
||||
color: #28a745;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.total-net-work-time {
|
||||
color: #155724;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 2px solid #28a745;
|
||||
}
|
||||
|
||||
.holiday-time {
|
||||
color: #6f42c1;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vacation-time {
|
||||
color: #17a2b8;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sick-time {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-complete {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-weekend {
|
||||
background: #e2e3e5;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.status-holiday {
|
||||
background: #e7d4f7;
|
||||
color: #5a1a8c;
|
||||
}
|
||||
|
||||
.status-holiday-work {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-left: 4px solid #6f42c1;
|
||||
}
|
||||
|
||||
.status-vacation-full {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.status-vacation-half {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 2px dashed #17a2b8;
|
||||
}
|
||||
|
||||
.status-vacation-work {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
.status-sick {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-row td {
|
||||
border-top: 2px solid #e9ecef;
|
||||
border-bottom: none;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.total-hours {
|
||||
font-size: 1rem;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.non-working-info {
|
||||
background: #fff3cd;
|
||||
border-top: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.non-working-label {
|
||||
font-size: 0.8rem;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.non-working-detail {
|
||||
font-weight: normal;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.non-working-total {
|
||||
font-size: 0.9rem;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.total-all-row {
|
||||
background: #e8f5e8;
|
||||
border-top: 2px solid #28a745;
|
||||
}
|
||||
|
||||
.total-all-hours {
|
||||
font-size: 1.1rem;
|
||||
color: #155724;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user