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:
Torsten Schulz (local)
2025-10-17 14:11:28 +02:00
commit e95bb4cb76
86 changed files with 19530 additions and 0 deletions

8
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.DS_Store
*.log
.vite

107
frontend/README.md Normal file
View File

@@ -0,0 +1,107 @@
# TimeClock Frontend v3.0
Vue 3 Frontend für die TimeClock Zeiterfassungsanwendung.
## Installation
```bash
npm install
```
## Entwicklung
```bash
npm run dev
```
Öffnen Sie `http://localhost:5010` in Ihrem Browser.
## Build
```bash
npm run build
```
Die Build-Dateien werden im `dist/` Ordner erstellt.
## Preview (nach Build)
```bash
npm run preview
```
## Technologien
- **Vue 3** - Progressive JavaScript Framework
- **Vue Router** - Offizielle Router-Bibliothek
- **Pinia** - State Management
- **Vite** - Next Generation Frontend Tooling
## Projektstruktur
```
src/
├── assets/ # CSS und statische Assets
├── components/ # Wiederverwendbare Komponenten
├── router/ # Vue Router Konfiguration
├── stores/ # Pinia Stores
├── views/ # Seiten-Komponenten
│ ├── Dashboard.vue
│ ├── Entries.vue
│ └── Stats.vue
├── App.vue # Root-Komponente
└── main.js # Entry Point
```
## Features
### Dashboard
- Timer starten/stoppen
- Echtzeit-Anzeige der laufenden Zeit
- Projekt und Beschreibung eingeben
- Letzte 5 Einträge anzeigen
### Einträge
- Tabellarische Übersicht aller Einträge
- Status-Anzeige (laufend/beendet)
- Einträge löschen
### Statistiken
- Gesamtstatistiken
- Projekt-basierte Auswertungen
- Visualisierung der Arbeitszeit
## State Management
Die Anwendung verwendet Pinia für zentrales State Management:
```javascript
// stores/timeStore.js
const timeStore = useTimeStore()
// Verfügbare Actions
timeStore.fetchEntries()
timeStore.startTimer({ project, description })
timeStore.stopTimer()
timeStore.deleteEntry(id)
timeStore.fetchStats()
```
## API-Konfiguration
Die API-URL ist in `src/stores/timeStore.js` konfiguriert:
```javascript
const API_URL = 'http://localhost:3010/api'
```
Für Produktion sollte dies in eine Environment-Variable verschoben werden.
## Styling
Die Anwendung verwendet:
- Custom CSS mit CSS Variables
- Moderne Gradients
- Responsive Design
- Card-basiertes Layout

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TimeClock v3 - Zeiterfassung</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "timeclock-frontend",
"version": "3.0.0",
"description": "TimeClock v3 - Frontend für Zeiterfassung",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.11",
"vue-router": "^4.2.5",
"pinia": "^2.1.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.8"
}
}

255
frontend/src/App.vue Normal file
View 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>&copy; 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>

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

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

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

View 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

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

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

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

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

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

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

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

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

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

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

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

22
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5010,
proxy: {
'/api': {
target: 'http://localhost:3010',
changeOrigin: true
}
}
}
})