Initial commit
2
.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_SUPABASE_URL=https://vzudjibddwcvfdnyvhhs.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0
|
||||||
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
|
||||||
|
RESEND_API_KEY=your-resend-api-key
|
||||||
|
REMINDER_FROM_EMAIL=PAT Stats <noreply@example.com>
|
||||||
|
REMINDER_APP_URL=https://your-app.example.com
|
||||||
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.vercel
|
||||||
|
node_modules
|
||||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"chatgpt.openOnStartup": true
|
||||||
|
}
|
||||||
BIN
Screenshot_1.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
886
Startseite.html
Normal file
@@ -0,0 +1,886 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PAT Test Manager | Professionelle Leistungsdiagnostik für Billard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #0f172a;
|
||||||
|
--primary-light: #1e293b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--text: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--surface: rgba(30, 41, 59, 0.6);
|
||||||
|
--border: rgba(148, 163, 184, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated Background */
|
||||||
|
.bg-animation {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 80%, rgba(59, 130, 246, 0.15) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 40% 40%, rgba(245, 158, 11, 0.05) 0%, transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
z-index: -1;
|
||||||
|
mask-image: linear-gradient(to bottom, transparent, black 20%, black 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem 5%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--text) 0%, var(--accent) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
-webkit-text-fill-color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 2.5rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8rem 5% 4rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2.5rem, 5vw, 4.5rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 span {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--success) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
max-width: 540px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 40px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Visual */
|
||||||
|
.hero-visual {
|
||||||
|
position: relative;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-preview {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
transform: rotateY(-5deg) rotateX(5deg);
|
||||||
|
transition: transform 0.5s;
|
||||||
|
box-shadow:
|
||||||
|
0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-preview:hover {
|
||||||
|
transform: rotateY(0deg) rotateX(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 75%;
|
||||||
|
animation: fillProgress 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fillProgress {
|
||||||
|
from { width: 0; }
|
||||||
|
to { width: 75%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features Section */
|
||||||
|
.features {
|
||||||
|
padding: 6rem 5%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
transition: all 0.4s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
border-color: rgba(59, 130, 246, 0.3);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(16, 185, 129, 0.2));
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* How it Works */
|
||||||
|
.how-it-works {
|
||||||
|
padding: 6rem 5%;
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(30, 41, 59, 0.3), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||||
|
opacity: 0.3;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 30px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Section */
|
||||||
|
.stats-section {
|
||||||
|
padding: 4rem 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 2rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 3rem;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item-number {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--text), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA Section */
|
||||||
|
.cta-section {
|
||||||
|
padding: 6rem 5%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-box {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(16, 185, 129, 0.1));
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: 4rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-box::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
|
||||||
|
animation: rotate 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-box h2 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-box p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
padding: 3rem 5%;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 968px) {
|
||||||
|
.hero-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-preview {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-container::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll Animations */
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
transition: opacity 0.6s, transform 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-animation"></div>
|
||||||
|
<div class="grid-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav>
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">🎯</div>
|
||||||
|
PAT Manager
|
||||||
|
</div>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="#features">Features</a></li>
|
||||||
|
<li><a href="#how-it-works">So funktioniert's</a></li>
|
||||||
|
<li><a href="#stats">Statistiken</a></li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://pat.web-sache.cloud/" class="nav-cta">Jetzt starten →</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-text">
|
||||||
|
<div class="hero-badge">
|
||||||
|
<span>⚡</span> Jetzt live verfügbar
|
||||||
|
</div>
|
||||||
|
<h1>
|
||||||
|
Professionelle <span>PAT-Test</span> Verwaltung
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Erfasse, analysiere und verfolge Pool Ability Tests digital.
|
||||||
|
Von der Eingabe bis zum Report – alles in einer intuitiven Plattform
|
||||||
|
für Trainer, Vereine und Spieler.
|
||||||
|
</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<a href="https://pat.web-sache.cloud/" class="btn-primary">
|
||||||
|
Kostenlos testen →
|
||||||
|
</a>
|
||||||
|
<a href="#features" class="btn-secondary">
|
||||||
|
Mehr erfahren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-visual">
|
||||||
|
<div class="dashboard-preview">
|
||||||
|
<div class="preview-header">
|
||||||
|
<div style="font-weight: 600;">Live Übersicht</div>
|
||||||
|
<div style="color: var(--success); font-size: 0.875rem;">● Online</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">PAT Aufschlag</div>
|
||||||
|
<div class="stat-value">4/6</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Gesamtpunktzahl</div>
|
||||||
|
<div class="stat-value" style="color: var(--success);">87%</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: 87%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<section class="features" id="features">
|
||||||
|
<div class="section-header fade-in">
|
||||||
|
<div class="section-tag">Funktionen</div>
|
||||||
|
<h2 class="section-title">Alles, was du für PAT-Tests brauchst</h2>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Von der digitalen Erfassung bis zur teamweiten Auswertung –
|
||||||
|
optimiert für den Einsatz am Billardtisch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card fade-in">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<h3>Smarte Auswertungen</h3>
|
||||||
|
<p>Automatische Berechnungen für jede PAT-Test-Kategorie.
|
||||||
|
Erkenne Stärken und Schwächen auf einen Blick mit visuellen Dashboards.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in">
|
||||||
|
<div class="feature-icon">🔒</div>
|
||||||
|
<h3>Sichere Speicherung</h3>
|
||||||
|
<p>Supabase-Backend mit verschlüsselten Daten.
|
||||||
|
Granulare Nutzersteuerung für dein gesamtes Team oder Trainingsteam.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in">
|
||||||
|
<div class="feature-icon">👥</div>
|
||||||
|
<h3>Team-ready</h3>
|
||||||
|
<p>Gemeinsam testen, bewerten und Versionen verwalten.
|
||||||
|
Kein Chaos mehr mit Excel-Tabellen oder Zettelwirtschaft.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in">
|
||||||
|
<div class="feature-icon">📈</div>
|
||||||
|
<h3>Performance-Insights</h3>
|
||||||
|
<p>Verfolge Fortschritte über Zeit, identifiziere Trends
|
||||||
|
und optimiere dein Training mit datengestützten Entscheidungen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Mobile Erfassung</h3>
|
||||||
|
<p>Direkte Dateneingabe am Court – schnell, einfach und
|
||||||
|
ohne Umwege. Perfekt für den Einsatz während des Trainings.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in">
|
||||||
|
<div class="feature-icon">📄</div>
|
||||||
|
<h3>Export & Reports</h3>
|
||||||
|
<p>Generiere professionelle Reports für Spieler, Trainer
|
||||||
|
oder Vereine. Teile Ergebnisse mit einem Klick.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- How it Works -->
|
||||||
|
<section class="how-it-works" id="how-it-works">
|
||||||
|
<div class="section-header fade-in">
|
||||||
|
<div class="section-tag">Prozess</div>
|
||||||
|
<h2 class="section-title">So läuft PAT mit uns</h2>
|
||||||
|
<p class="section-subtitle">Von der Testvorlage bis zum Report – alles in einem Flow</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps-container fade-in">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">1</div>
|
||||||
|
<h3>Vorlage wählen</h3>
|
||||||
|
<p>Wähle den PAT-Test, setze Namen und Datum – los geht's.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">2</div>
|
||||||
|
<h3>Daten erfassen</h3>
|
||||||
|
<p>Einzelwerte direkt am Court eingeben, automatisch verrechnen lassen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">3</div>
|
||||||
|
<h3>Analysieren</h3>
|
||||||
|
<p>Ergebnisse speichern, visualisieren und mit dem Team teilen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<section class="stats-section" id="stats">
|
||||||
|
<div class="stats-grid fade-in">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-number">100%</span>
|
||||||
|
<div class="stat-item-label">Digital</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-number">0€</span>
|
||||||
|
<div class="stat-item-label">Kostenlos starten</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-number">SSL</span>
|
||||||
|
<div class="stat-item-label">Verschlüsselt</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-number">24/7</span>
|
||||||
|
<div class="stat-item-label">Verfügbar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="cta-section">
|
||||||
|
<div class="cta-box fade-in">
|
||||||
|
<div class="cta-content">
|
||||||
|
<h2>Bereit, deine PAT-Tests zu professionalisieren?</h2>
|
||||||
|
<p>Starte in Minuten. Keine Kreditkarte erforderlich.</p>
|
||||||
|
<a href="https://pat.web-sache.cloud/" class="btn-primary" style="font-size: 1.2rem; padding: 1.25rem 2.5rem;">
|
||||||
|
Jetzt kostenlos ausprobieren →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer>
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="https://pat.web-sache.cloud/">App öffnen</a></li>
|
||||||
|
<li><a href="#">Datenschutz</a></li>
|
||||||
|
<li><a href="#">Impressum</a></li>
|
||||||
|
<li><a href="#">Kontakt</a></li>
|
||||||
|
</ul>
|
||||||
|
<p>© 2024 PAT Test Manager. Alle Rechte vorbehalten.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Scroll Animation
|
||||||
|
const observerOptions = {
|
||||||
|
threshold: 0.1,
|
||||||
|
rootMargin: "0px 0px -50px 0px"
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
|
||||||
|
|
||||||
|
// Smooth Scroll
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.querySelector(this.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
Weitere Ideen.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Offen
|
||||||
|
nere Landing Page
|
||||||
|
Rangliste
|
||||||
|
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
|
||||||
|
Max Punkte bei Bewertung PAT Start, Pat2 und Pat3
|
||||||
|
Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
|
||||||
|
2fa
|
||||||
|
Erledigt
|
||||||
|
Final Speichern do
|
||||||
|
Max Punkte bei Bewertung PAT1
|
||||||
|
|
||||||
|
|
||||||
|
Termninal
|
||||||
|
vercel deploy
|
||||||
22
dist/assets/html2canvas.esm-CBrSDip1.js
vendored
Normal file
1
dist/assets/index-BQ19ytI3.css
vendored
Normal file
84
dist/assets/index-DlZengFf.js
vendored
Normal file
18
dist/assets/index.es-UMgmYbRN.js
vendored
Normal file
170
dist/assets/jspdf.es.min-CzU92G48.js
vendored
Normal file
2
dist/assets/jspdf.plugin.autotable-CyVc7Jkq.js
vendored
Normal file
2
dist/assets/purify.es-BgtpMKW3.js
vendored
Normal file
14
dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PAT Test Manager</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DlZengFf.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BQ19ytI3.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PAT Test Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4058
package-lock.json
generated
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "pat-test-manager",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"migrate": "node scripts/applyMigrations.js",
|
||||||
|
"send-finalization-reminders": "node scripts/sendFinalizationReminders.js",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.45.4",
|
||||||
|
"all": "^0.0.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
|
"lucide-react": "^0.263.1",
|
||||||
|
"pg": "^8.11.5",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
97
scripts/applyMigrations.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Lightweight migration runner for Supabase.
|
||||||
|
* Reads SQL files in supabase/migrations (sorted) and applies any that have not been recorded.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* - SUPABASE_DB_URL or DATABASE_URL: postgres connection string (service role / connection string)
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const migrationsDir = path.resolve(__dirname, '..', 'supabase', 'migrations');
|
||||||
|
const connectionString = process.env.SUPABASE_DB_URL || process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('Missing SUPABASE_DB_URL (or DATABASE_URL) environment variable. Aborting migrations.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(migrationsDir)) {
|
||||||
|
console.error(`Migrations directory not found: ${migrationsDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
|
||||||
|
async function ensureMigrationsTable(client) {
|
||||||
|
await client.query(`
|
||||||
|
create table if not exists public.schema_migrations (
|
||||||
|
id serial primary key,
|
||||||
|
filename text not null unique,
|
||||||
|
applied_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApplied(client) {
|
||||||
|
const { rows } = await client.query('select filename from public.schema_migrations');
|
||||||
|
return new Set(rows.map((r) => r.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMigrations() {
|
||||||
|
return fs
|
||||||
|
.readdirSync(migrationsDir)
|
||||||
|
.filter((file) => file.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMigration(client, filename) {
|
||||||
|
const filePath = path.join(migrationsDir, filename);
|
||||||
|
const sql = fs.readFileSync(filePath, 'utf8');
|
||||||
|
console.log(`Applying migration: ${filename}`);
|
||||||
|
await client.query('begin');
|
||||||
|
try {
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query('insert into public.schema_migrations (filename) values ($1)', [filename]);
|
||||||
|
await client.query('commit');
|
||||||
|
console.log(`✓ Applied ${filename}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('rollback');
|
||||||
|
console.error(`✗ Failed ${filename}:`, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await ensureMigrationsTable(client);
|
||||||
|
const applied = await getApplied(client);
|
||||||
|
const migrations = listMigrations();
|
||||||
|
|
||||||
|
const pending = migrations.filter((m) => !applied.has(m));
|
||||||
|
if (pending.length === 0) {
|
||||||
|
console.log('No pending migrations.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const migration of pending) {
|
||||||
|
await applyMigration(client, migration);
|
||||||
|
}
|
||||||
|
console.log('All pending migrations applied.');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error('Migration run failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
130
scripts/sendFinalizationReminders.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
const connectionString = process.env.SUPABASE_DB_URL || process.env.DATABASE_URL;
|
||||||
|
const resendApiKey = process.env.RESEND_API_KEY;
|
||||||
|
const fromEmail = process.env.REMINDER_FROM_EMAIL;
|
||||||
|
const appUrl = process.env.REMINDER_APP_URL || 'http://localhost:5173';
|
||||||
|
const dryRun = process.env.REMINDER_DRY_RUN === 'true';
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('Missing SUPABASE_DB_URL (or DATABASE_URL).');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun && (!resendApiKey || !fromEmail)) {
|
||||||
|
console.error('Missing RESEND_API_KEY or REMINDER_FROM_EMAIL.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
|
||||||
|
const findPendingReminders = async (client) => {
|
||||||
|
const { rows } = await client.query(`
|
||||||
|
select
|
||||||
|
a.id,
|
||||||
|
a.name,
|
||||||
|
a.pat_type,
|
||||||
|
a.datum,
|
||||||
|
a.created_at,
|
||||||
|
u.email
|
||||||
|
from public.assessments a
|
||||||
|
join auth.users u on u.id = a.user_id
|
||||||
|
where a.is_finalized = false
|
||||||
|
and a.created_at <= now() - interval '3 days'
|
||||||
|
and a.finalization_reminder_sent_at is null
|
||||||
|
and coalesce(u.email, '') <> ''
|
||||||
|
order by a.created_at asc
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEmailPayload = (row) => {
|
||||||
|
const subject = `PAT Test noch abschließen: ${row.name || row.pat_type}`;
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||||
|
<h2 style="margin-bottom: 12px;">PAT Test noch abschließen</h2>
|
||||||
|
<p>Dein offener Test wurde seit mehr als 3 Tagen nicht final abgeschlossen.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Name:</strong> ${row.name || '—'}</li>
|
||||||
|
<li><strong>PAT Typ:</strong> ${row.pat_type || '—'}</li>
|
||||||
|
<li><strong>Datum:</strong> ${row.datum || '—'}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Bitte öffne PAT Stats und schließe den Test final ab.</p>
|
||||||
|
<p>
|
||||||
|
<a href="${appUrl}" style="display: inline-block; margin-top: 8px; padding: 10px 16px; background: #059669; color: #ffffff; text-decoration: none; border-radius: 6px;">
|
||||||
|
PAT Stats öffnen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: fromEmail,
|
||||||
|
to: [row.email],
|
||||||
|
subject,
|
||||||
|
html
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendEmail = async (payload) => {
|
||||||
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${resendApiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(`Resend request failed: ${response.status} ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markReminderSent = async (client, assessmentId) => {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
update public.assessments
|
||||||
|
set finalization_reminder_sent_at = now()
|
||||||
|
where id = $1
|
||||||
|
and is_finalized = false
|
||||||
|
and finalization_reminder_sent_at is null
|
||||||
|
`,
|
||||||
|
[assessmentId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await findPendingReminders(client);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log('No pending finalization reminders.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] Would remind ${row.email} for assessment ${row.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmail(buildEmailPayload(row));
|
||||||
|
await markReminderSent(client, row.id);
|
||||||
|
console.log(`Reminder sent for assessment ${row.id} to ${row.email}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error('Failed to send finalization reminders:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
255
src/App.jsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import PatTestManager from './components/PatTestManager'
|
||||||
|
import AuthPanel from './components/AuthPanel'
|
||||||
|
import { supabase } from './lib/supabaseClient'
|
||||||
|
import Landing from './components/Landing'
|
||||||
|
import SharedAssessmentPage from './components/SharedAssessmentPage'
|
||||||
|
import PublicInfoPage from './components/public/PublicInfoPage'
|
||||||
|
|
||||||
|
const readShareToken = () => {
|
||||||
|
if (typeof window === 'undefined') return ''
|
||||||
|
return new URLSearchParams(window.location.search).get('share') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const readPublicPath = () => {
|
||||||
|
if (typeof window === 'undefined') return '/'
|
||||||
|
const hash = window.location.hash || ''
|
||||||
|
return hash.startsWith('#/') ? hash.slice(1) : '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [session, setSession] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAuth, setShowAuth] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('assessments')
|
||||||
|
const [isEditingAssessment, setIsEditingAssessment] = useState(false)
|
||||||
|
const [shareToken, setShareToken] = useState(() => readShareToken())
|
||||||
|
const [publicPath, setPublicPath] = useState(() => readPublicPath())
|
||||||
|
const [theme, setTheme] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
const stored = localStorage.getItem('theme')
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
localStorage.setItem('theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncLocationState = () => {
|
||||||
|
setShareToken(readShareToken())
|
||||||
|
setPublicPath(readPublicPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', syncLocationState)
|
||||||
|
window.addEventListener('hashchange', syncLocationState)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', syncLocationState)
|
||||||
|
window.removeEventListener('hashchange', syncLocationState)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shareToken) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSession = async () => {
|
||||||
|
const { data, error } = await supabase.auth.getSession()
|
||||||
|
if (!error) {
|
||||||
|
setSession(data.session)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSession()
|
||||||
|
|
||||||
|
const { data: authListener } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
setSession(newSession)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
authListener?.subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
}, [shareToken])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditingAssessment && activeTab === 'analysis') {
|
||||||
|
setActiveTab('assessments')
|
||||||
|
}
|
||||||
|
}, [activeTab, isEditingAssessment])
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
if (!supabase) return
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
setSession(null)
|
||||||
|
setShowAuth(false)
|
||||||
|
setActiveTab('assessments')
|
||||||
|
setIsEditingAssessment(false)
|
||||||
|
navigatePublic('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||||
|
|
||||||
|
const navigatePublic = (path) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const nextPath = path || '/'
|
||||||
|
const nextHash = nextPath === '/' ? '' : `#${nextPath}`
|
||||||
|
|
||||||
|
if (window.location.hash === nextHash) {
|
||||||
|
setPublicPath(nextPath)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`
|
||||||
|
window.history.pushState({}, '', nextUrl)
|
||||||
|
setPublicPath(nextPath)
|
||||||
|
setShareToken(readShareToken())
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderThemeToggle = () => (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 shadow-sm hover:shadow transition"
|
||||||
|
aria-label="Darstellung wechseln"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
<span className="text-sm font-medium">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shareToken) {
|
||||||
|
return <SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 flex items-center justify-center p-6">
|
||||||
|
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
|
||||||
|
<div className="max-w-xl w-full bg-white/90 dark:bg-gray-900/80 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl shadow-sm p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">Supabase noch nicht konfiguriert</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
Lege eine <code>.env</code> an (siehe <code>.env.example</code>) und trage
|
||||||
|
<code> VITE_SUPABASE_URL </code> sowie <code>VITE_SUPABASE_ANON_KEY</code> ein, um Login und
|
||||||
|
Registrierung zu aktivieren.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
|
||||||
|
Anschließend Vite neu starten, damit die Variablen geladen werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
|
||||||
|
<span>Auth wird geladen...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
if (showAuth) {
|
||||||
|
return <AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
|
||||||
|
return (
|
||||||
|
<PublicInfoPage
|
||||||
|
path={publicPath}
|
||||||
|
onGetStarted={() => setShowAuth(true)}
|
||||||
|
onNavigate={navigatePublic}
|
||||||
|
themeToggle={renderThemeToggle()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Landing
|
||||||
|
onGetStarted={() => setShowAuth(true)}
|
||||||
|
onNavigate={navigatePublic}
|
||||||
|
themeToggle={renderThemeToggle()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||||
|
<header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Angemeldet</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">{session?.user?.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('assessments')}
|
||||||
|
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
|
||||||
|
activeTab === 'assessments'
|
||||||
|
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
|
||||||
|
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Bewertungen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('analysis')}
|
||||||
|
disabled={isEditingAssessment}
|
||||||
|
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
|
||||||
|
activeTab === 'analysis'
|
||||||
|
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
|
||||||
|
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title={isEditingAssessment ? 'Analyse ist während der Bearbeitung deaktiviert' : 'Analyse öffnen'}
|
||||||
|
>
|
||||||
|
Analyse
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{renderThemeToggle()}
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<PatTestManager
|
||||||
|
user={session?.user}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onEditingStateChange={setIsEditingAssessment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
164
src/components/Analysis/AnalysisTab.jsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { Activity, AlertTriangle, Brain, Target } from 'lucide-react';
|
||||||
|
import { buildAnalysis } from '../../utils/analysisEngine';
|
||||||
|
import { useTrainingPlans } from '../../hooks/useTrainingPlans';
|
||||||
|
import GapBarChart from './GapBarChart';
|
||||||
|
import RadarChart from './RadarChart';
|
||||||
|
import StrengthList from './StrengthList';
|
||||||
|
import TrainingPlanPanel from './TrainingPlanPanel';
|
||||||
|
import TrendChart from './TrendChart';
|
||||||
|
import WeaknessPriorityList from './WeaknessPriorityList';
|
||||||
|
|
||||||
|
export default function AnalysisTab({
|
||||||
|
assessments,
|
||||||
|
selectedPatType,
|
||||||
|
onPatTypeChange,
|
||||||
|
patTypes,
|
||||||
|
user
|
||||||
|
}) {
|
||||||
|
const patTypeOptions = Object.keys(patTypes || {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPatType && patTypeOptions.length) {
|
||||||
|
onPatTypeChange(patTypeOptions[0]);
|
||||||
|
}
|
||||||
|
}, [onPatTypeChange, patTypeOptions, selectedPatType]);
|
||||||
|
|
||||||
|
const analysis = useMemo(
|
||||||
|
() =>
|
||||||
|
buildAnalysis(assessments, selectedPatType, {
|
||||||
|
windowSize: 5,
|
||||||
|
missingStrategy: 'zero'
|
||||||
|
}),
|
||||||
|
[assessments, selectedPatType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPatAssessments = assessments.filter((assessment) => assessment.patType === selectedPatType);
|
||||||
|
const hasAssessments = assessments.length > 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
activePlan,
|
||||||
|
historyPlans,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
saveGeneratedPlan,
|
||||||
|
updateSessionState,
|
||||||
|
updateSessionPartial,
|
||||||
|
loadPlans
|
||||||
|
} = useTrainingPlans({
|
||||||
|
user,
|
||||||
|
patType: selectedPatType
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSavePlan = ({ durationWeeks, sessionsPerWeek, sessions }) =>
|
||||||
|
saveGeneratedPlan({
|
||||||
|
analysis,
|
||||||
|
durationWeeks,
|
||||||
|
sessionsPerWeek,
|
||||||
|
sessions
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-cyan-50 via-white to-emerald-50 dark:from-gray-950 dark:via-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/80 backdrop-blur p-5 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">Analyse</p>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Wo solltest du besser werden?</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Priorisierung nach Gap × Faktor × Konstanz basierend auf den letzten 5 Assessments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{patTypeOptions.map((patType) => {
|
||||||
|
const isActive = patType === selectedPatType;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={patType}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPatTypeChange(patType)}
|
||||||
|
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
|
||||||
|
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{patType}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="px-3 py-1 rounded-full border border-cyan-200 dark:border-cyan-800 bg-cyan-50 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-200 font-semibold">
|
||||||
|
<Activity className="w-3 h-3 inline-block mr-1" />
|
||||||
|
Assessments: {selectedPatAssessments.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="px-3 py-1 rounded-full border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-200 font-semibold">
|
||||||
|
<Target className="w-3 h-3 inline-block mr-1" />
|
||||||
|
Analysefenster: {analysis.windowedAssessments.length}/5
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{analysis.isLimitedData && (
|
||||||
|
<span className="px-3 py-1 rounded-full border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200 font-semibold">
|
||||||
|
<AlertTriangle className="w-3 h-3 inline-block mr-1" />
|
||||||
|
Begrenzte Datenbasis
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!hasAssessments ? (
|
||||||
|
<section className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/70 p-6 text-center">
|
||||||
|
<Brain className="w-10 h-10 mx-auto mb-3 text-gray-400 dark:text-gray-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">Noch keine Daten für die Analyse</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">Lege zuerst eine Bewertung im Bereich "Bewertungen" an.</p>
|
||||||
|
</section>
|
||||||
|
) : selectedPatAssessments.length === 0 ? (
|
||||||
|
<section className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/70 p-6 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">Keine Bewertungen für {selectedPatType}</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">Wähle einen anderen PAT-Typ oder erstelle eine neue Bewertung.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="grid lg:grid-cols-2 gap-4">
|
||||||
|
<div className="h-full">
|
||||||
|
<GapBarChart metrics={analysis.exerciseMetrics} />
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<RadarChart radarSeries={analysis.radarSeries} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<TrendChart trendSeries={analysis.trendSeries} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid lg:grid-cols-2 gap-4">
|
||||||
|
<WeaknessPriorityList weaknesses={analysis.topWeaknesses} />
|
||||||
|
<StrengthList strengths={analysis.topStrengths} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<TrainingPlanPanel
|
||||||
|
patType={selectedPatType}
|
||||||
|
analysis={analysis}
|
||||||
|
activePlan={activePlan}
|
||||||
|
historyPlans={historyPlans}
|
||||||
|
loading={loading}
|
||||||
|
saving={saving}
|
||||||
|
error={error}
|
||||||
|
onSavePlan={handleSavePlan}
|
||||||
|
onUpdateSessionState={updateSessionState}
|
||||||
|
onUpdateSessionPartial={updateSessionPartial}
|
||||||
|
onReload={loadPlans}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/Analysis/GapBarChart.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const truncate = (value, maxLength = 26) =>
|
||||||
|
value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||||
|
|
||||||
|
export default function GapBarChart({ metrics = [] }) {
|
||||||
|
const data = [...metrics]
|
||||||
|
.sort((left, right) => right.priorityScore - left.priorityScore)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Kein Gap-Chart verfügbar.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 760;
|
||||||
|
const leftPad = 220;
|
||||||
|
const rightPad = 24;
|
||||||
|
const rowHeight = 38;
|
||||||
|
const topPad = 20;
|
||||||
|
const bottomPad = 24;
|
||||||
|
const height = topPad + bottomPad + rowHeight * data.length;
|
||||||
|
|
||||||
|
const maxScore = Math.max(...data.map((item) => item.priorityScore), 0.01);
|
||||||
|
const chartWidth = width - leftPad - rightPad;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Gap-Bar-Chart (Priorität)</h3>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Gap-Balkendiagramm">
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const y = topPad + index * rowHeight;
|
||||||
|
const barWidth = (item.priorityScore / maxScore) * chartWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={item.name}>
|
||||||
|
<text
|
||||||
|
x={10}
|
||||||
|
y={y + 20}
|
||||||
|
className="fill-gray-700 dark:fill-gray-200"
|
||||||
|
style={{ fontSize: 12, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{truncate(item.name)}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={leftPad}
|
||||||
|
y={y + 8}
|
||||||
|
width={chartWidth}
|
||||||
|
height={16}
|
||||||
|
rx={8}
|
||||||
|
className="fill-gray-200 dark:fill-gray-800"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={leftPad}
|
||||||
|
y={y + 8}
|
||||||
|
width={Math.max(3, barWidth)}
|
||||||
|
height={16}
|
||||||
|
rx={8}
|
||||||
|
className="fill-rose-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x={leftPad + Math.max(8, barWidth) + 8}
|
||||||
|
y={y + 20}
|
||||||
|
className="fill-gray-700 dark:fill-gray-300"
|
||||||
|
style={{ fontSize: 11, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{item.priorityScore.toFixed(2)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/Analysis/RadarChart.jsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const polarToCartesian = (centerX, centerY, radius, angle) => ({
|
||||||
|
x: centerX + radius * Math.cos(angle),
|
||||||
|
y: centerY + radius * Math.sin(angle)
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value);
|
||||||
|
|
||||||
|
export default function RadarChart({ radarSeries = [] }) {
|
||||||
|
const data = radarSeries.slice(0, 6);
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Kein Radarprofil verfügbar.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 420;
|
||||||
|
const height = 320;
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const radius = 105;
|
||||||
|
|
||||||
|
const angleStep = (Math.PI * 2) / data.length;
|
||||||
|
|
||||||
|
const toPoint = (ratio, idx) => {
|
||||||
|
const angle = -Math.PI / 2 + idx * angleStep;
|
||||||
|
return polarToCartesian(centerX, centerY, radius * (Math.max(0, Math.min(1.2, ratio)) / 1.2), angle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const polygon = data
|
||||||
|
.map((item, idx) => {
|
||||||
|
const point = toPoint(item.ratio, idx);
|
||||||
|
return `${point.x},${point.y}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Radar-Profil (Ist/Soll)</h3>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label="Radarprofil">
|
||||||
|
{[0.25, 0.5, 0.75, 1].map((level) => (
|
||||||
|
<polygon
|
||||||
|
key={level}
|
||||||
|
points={data
|
||||||
|
.map((_, idx) => {
|
||||||
|
const angle = -Math.PI / 2 + idx * angleStep;
|
||||||
|
const point = polarToCartesian(centerX, centerY, radius * level, angle);
|
||||||
|
return `${point.x},${point.y}`;
|
||||||
|
})
|
||||||
|
.join(' ')}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-gray-300 dark:stroke-gray-700"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.map((item, idx) => {
|
||||||
|
const angle = -Math.PI / 2 + idx * angleStep;
|
||||||
|
const outerPoint = polarToCartesian(centerX, centerY, radius + 16, angle);
|
||||||
|
const axisEnd = polarToCartesian(centerX, centerY, radius, angle);
|
||||||
|
return (
|
||||||
|
<g key={item.name}>
|
||||||
|
<line
|
||||||
|
x1={centerX}
|
||||||
|
y1={centerY}
|
||||||
|
x2={axisEnd.x}
|
||||||
|
y2={axisEnd.y}
|
||||||
|
className="stroke-gray-300 dark:stroke-gray-700"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={outerPoint.x}
|
||||||
|
y={outerPoint.y}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-gray-600 dark:fill-gray-300"
|
||||||
|
style={{ fontSize: 10 }}
|
||||||
|
>
|
||||||
|
{truncate(item.name)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<polygon points={polygon} className="fill-sky-300/40 dark:fill-sky-500/30 stroke-sky-600 dark:stroke-sky-400" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/Analysis/StrengthList.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
|
||||||
|
|
||||||
|
export default function StrengthList({ strengths = [] }) {
|
||||||
|
if (!strengths.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Keine Stärken-Daten verfügbar.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-900 bg-white dark:bg-gray-900 p-5 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-emerald-700 dark:text-emerald-300 mb-4">Top-3 Stärken</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{strengths.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="rounded-xl border border-emerald-100 dark:border-emerald-900/60 bg-emerald-50/60 dark:bg-emerald-950/20 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{index + 1}. {item.name}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-emerald-200/70 dark:bg-emerald-800/60 text-emerald-800 dark:text-emerald-200 font-semibold">
|
||||||
|
{scoreToPercent(item.strengthScore)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<p>Ist: {item.actualMean.toFixed(2)}</p>
|
||||||
|
<p>Soll: {item.soll.toFixed(2)}</p>
|
||||||
|
<p>Trend: {item.trendDelta.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
src/components/Analysis/TrainingPlanPanel.jsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { generateTrainingPlan } from '../../utils/trainingPlanGenerator';
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskWithUpdatedIntensity = (task, intensity) => {
|
||||||
|
if (task.type !== 'main') return task;
|
||||||
|
|
||||||
|
const repetitionsByIntensity = {
|
||||||
|
hoch: 24,
|
||||||
|
mittel: 18,
|
||||||
|
leicht: 12
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
intensity,
|
||||||
|
repetitions: repetitionsByIntensity[intensity] || 18
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusButtonClass = (active) =>
|
||||||
|
active
|
||||||
|
? 'bg-indigo-600 text-white border-indigo-600'
|
||||||
|
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700';
|
||||||
|
|
||||||
|
export default function TrainingPlanPanel({
|
||||||
|
patType,
|
||||||
|
analysis,
|
||||||
|
activePlan,
|
||||||
|
historyPlans,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
onSavePlan,
|
||||||
|
onUpdateSessionState,
|
||||||
|
onUpdateSessionPartial,
|
||||||
|
onReload
|
||||||
|
}) {
|
||||||
|
const [durationWeeks, setDurationWeeks] = useState(4);
|
||||||
|
const [sessionsPerWeek, setSessionsPerWeek] = useState(3);
|
||||||
|
const [draftSessions, setDraftSessions] = useState([]);
|
||||||
|
const [uiMessage, setUiMessage] = useState('');
|
||||||
|
|
||||||
|
const draftSummary = useMemo(() => {
|
||||||
|
const total = draftSessions.length;
|
||||||
|
const high = draftSessions.filter((session) => session.intensity === 'hoch').length;
|
||||||
|
const medium = draftSessions.filter((session) => session.intensity === 'mittel').length;
|
||||||
|
const low = draftSessions.filter((session) => session.intensity === 'leicht').length;
|
||||||
|
|
||||||
|
return { total, high, medium, low };
|
||||||
|
}, [draftSessions]);
|
||||||
|
|
||||||
|
const handleGenerateDraft = () => {
|
||||||
|
const generated = generateTrainingPlan({
|
||||||
|
topWeaknesses: analysis?.topWeaknesses || [],
|
||||||
|
topStrengths: analysis?.topStrengths || [],
|
||||||
|
durationWeeks,
|
||||||
|
sessionsPerWeek,
|
||||||
|
sessionMix: '1-main-2-secondary'
|
||||||
|
});
|
||||||
|
|
||||||
|
setDraftSessions(generated.sessions);
|
||||||
|
setUiMessage('Neuer Entwurf erstellt. Du kannst jetzt Intensität und Übungen je Session anpassen.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDraftSession = (index, patch) => {
|
||||||
|
setDraftSessions((previous) =>
|
||||||
|
previous.map((session, currentIndex) => {
|
||||||
|
if (currentIndex !== index) return session;
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...session,
|
||||||
|
...patch
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'intensity')) {
|
||||||
|
next.tasks = next.tasks.map((task) => taskWithUpdatedIntensity(task, patch.intensity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
if (!draftSessions.length) {
|
||||||
|
setUiMessage('Erstelle zuerst einen Entwurf.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onSavePlan({
|
||||||
|
analysis,
|
||||||
|
durationWeeks,
|
||||||
|
sessionsPerWeek,
|
||||||
|
sessions: draftSessions
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.ok) {
|
||||||
|
setUiMessage(result?.error?.message || 'Trainingsplan konnte nicht gespeichert werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUiMessage('Trainingsplan gespeichert. Der vorherige aktive Plan wurde archiviert.');
|
||||||
|
setDraftSessions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSessionEditor = (session, index) => {
|
||||||
|
const secondaryAsText = session.secondaryExercises.join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`draft-${index}`} className="rounded-xl border border-sky-100 dark:border-sky-900/60 bg-sky-50/60 dark:bg-sky-950/20 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-semibold text-sky-800 dark:text-sky-200">
|
||||||
|
Woche {session.weekNo} · Einheit {session.sessionNo}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-sky-700 dark:text-sky-300">{session.intensity}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
value={session.mainExercise}
|
||||||
|
onChange={(event) => updateDraftSession(index, { mainExercise: event.target.value })}
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
placeholder="Hauptübung"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={secondaryAsText}
|
||||||
|
onChange={(event) => {
|
||||||
|
const secondaryExercises = event.target.value
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
updateDraftSession(index, {
|
||||||
|
secondaryExercises:
|
||||||
|
secondaryExercises.length === 2
|
||||||
|
? secondaryExercises
|
||||||
|
: [...secondaryExercises, 'Positionsspiel Basis'].slice(0, 2)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
placeholder="Nebenübungen, getrennt mit Komma"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={session.intensity}
|
||||||
|
onChange={(event) => updateDraftSession(index, { intensity: event.target.value })}
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<option value="hoch">hoch</option>
|
||||||
|
<option value="mittel">mittel</option>
|
||||||
|
<option value="leicht">leicht</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-5 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Trainingsplan ({patType})</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
1 aktiver Plan + Historie, Session-Status: offen / erledigt / übersprungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onReload}
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-lg border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-4 gap-3 mb-4">
|
||||||
|
<label className="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
Dauer
|
||||||
|
<select
|
||||||
|
value={durationWeeks}
|
||||||
|
onChange={(event) => setDurationWeeks(Number(event.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<option value={2}>2 Wochen</option>
|
||||||
|
<option value={4}>4 Wochen</option>
|
||||||
|
<option value={6}>6 Wochen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
Einheiten/Woche
|
||||||
|
<select
|
||||||
|
value={sessionsPerWeek}
|
||||||
|
onChange={(event) => setSessionsPerWeek(Number(event.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<option value={2}>2</option>
|
||||||
|
<option value={3}>3</option>
|
||||||
|
<option value={4}>4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateDraft}
|
||||||
|
type="button"
|
||||||
|
className="md:self-end px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Plan (neu) generieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !draftSessions.length}
|
||||||
|
className="md:self-end px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichert…' : 'Entwurf speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uiMessage && (
|
||||||
|
<div className="mb-4 rounded-lg border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/30 p-3 text-sm text-indigo-700 dark:text-indigo-200">
|
||||||
|
{uiMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftSessions.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Entwurf (teilweise editierbar)</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Sessions: {draftSummary.total} · hoch {draftSummary.high} · mittel {draftSummary.medium} · leicht {draftSummary.low}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">{draftSessions.map(renderSessionEditor)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-xl border border-emerald-100 dark:border-emerald-900/60 p-3 bg-emerald-50/50 dark:bg-emerald-950/20">
|
||||||
|
<h4 className="font-semibold text-emerald-800 dark:text-emerald-200 mb-2">Aktiver Plan</h4>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">Lädt…</p>
|
||||||
|
) : !activePlan ? (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">Noch kein aktiver Trainingsplan vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300 mb-2">
|
||||||
|
Generiert: {formatDateTime(activePlan.generatedAt)} · {activePlan.durationWeeks} Wochen · {activePlan.sessionsPerWeek} Einheiten/Woche
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">
|
||||||
|
{activePlan.sessions.map((session) => (
|
||||||
|
<div key={session.id} className="rounded-lg border border-emerald-200/70 dark:border-emerald-800 bg-white dark:bg-gray-900 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
W{session.weekNo} · E{session.sessionNo}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['open', 'done', 'skipped'].map((state) => (
|
||||||
|
<button
|
||||||
|
key={state}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateSessionState(session.id, state)}
|
||||||
|
className={`text-xs px-2 py-1 border rounded ${statusButtonClass(session.state === state)}`}
|
||||||
|
>
|
||||||
|
{state === 'open' ? 'offen' : state === 'done' ? 'erledigt' : 'übersprungen'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
defaultValue={session.mainExercise}
|
||||||
|
onBlur={(event) =>
|
||||||
|
onUpdateSessionPartial(session.id, {
|
||||||
|
mainExercise: event.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full mb-2 px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
defaultValue={session.secondaryExercises.join(', ')}
|
||||||
|
onBlur={(event) =>
|
||||||
|
onUpdateSessionPartial(session.id, {
|
||||||
|
secondaryExercises: event.target.value
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full mb-2 px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
defaultValue={session.notes || ''}
|
||||||
|
onBlur={(event) => onUpdateSessionPartial(session.id, { notes: event.target.value })}
|
||||||
|
placeholder="Notiz"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-gray-50 dark:bg-gray-950/40">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Plan-Historie</h4>
|
||||||
|
|
||||||
|
{!historyPlans.length ? (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">Keine archivierten Pläne vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
|
||||||
|
{historyPlans.map((plan) => (
|
||||||
|
<div key={plan.id} className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-3">
|
||||||
|
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{formatDateTime(plan.generatedAt)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
{plan.durationWeeks} Wochen · {plan.sessionsPerWeek} Einheiten/Woche · {plan.sessions.length} Sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/Analysis/TrendChart.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function TrendChart({ trendSeries = [] }) {
|
||||||
|
if (trendSeries.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Für den Trend werden mindestens 2 Assessments benötigt.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 760;
|
||||||
|
const height = 280;
|
||||||
|
const left = 50;
|
||||||
|
const right = 20;
|
||||||
|
const top = 16;
|
||||||
|
const bottom = 52;
|
||||||
|
|
||||||
|
const values = trendSeries.map((entry) => entry.totalPoints);
|
||||||
|
const minValue = Math.min(...values);
|
||||||
|
const maxValue = Math.max(...values);
|
||||||
|
const range = maxValue - minValue || 1;
|
||||||
|
|
||||||
|
const innerWidth = width - left - right;
|
||||||
|
const innerHeight = height - top - bottom;
|
||||||
|
|
||||||
|
const points = trendSeries.map((entry, index) => {
|
||||||
|
const x = left + (index / (trendSeries.length - 1)) * innerWidth;
|
||||||
|
const normalized = (entry.totalPoints - minValue) / range;
|
||||||
|
const y = top + innerHeight - normalized * innerHeight;
|
||||||
|
return { ...entry, x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x},${point.y}`).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Trend (Gesamtpunkte)</h3>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Trenddiagramm">
|
||||||
|
<line x1={left} y1={top} x2={left} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
|
||||||
|
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
|
||||||
|
|
||||||
|
<path d={path} fill="none" className="stroke-indigo-500" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{points.map((point) => (
|
||||||
|
<g key={point.assessmentId}>
|
||||||
|
<circle cx={point.x} cy={point.y} r="4" className="fill-indigo-500" />
|
||||||
|
<text x={point.x} y={point.y - 10} textAnchor="middle" className="fill-gray-700 dark:fill-gray-200" style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{point.totalPoints}
|
||||||
|
</text>
|
||||||
|
<text x={point.x} y={height - 18} textAnchor="middle" className="fill-gray-500 dark:fill-gray-400" style={{ fontSize: 10 }}>
|
||||||
|
{point.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/Analysis/WeaknessPriorityList.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
|
||||||
|
|
||||||
|
export default function WeaknessPriorityList({ weaknesses = [] }) {
|
||||||
|
if (!weaknesses.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Keine Schwächen-Daten verfügbar.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-rose-200 dark:border-rose-900 bg-white dark:bg-gray-900 p-5 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-rose-700 dark:text-rose-300 mb-4">Top-3 Schwächen</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weaknesses.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="rounded-xl border border-rose-100 dark:border-rose-900/60 bg-rose-50/60 dark:bg-rose-950/20 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{index + 1}. {item.name}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-rose-200/70 dark:bg-rose-800/60 text-rose-800 dark:text-rose-200 font-semibold">
|
||||||
|
{scoreToPercent(item.priorityScore)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<p>Gap: {item.gapScore.toFixed(2)}</p>
|
||||||
|
<p>Konstanz: {item.consistencyScore.toFixed(2)}</p>
|
||||||
|
<p>Trend: {item.trendDelta.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/AuthPanel.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { supabase } from '../lib/supabaseClient'
|
||||||
|
|
||||||
|
export default function AuthPanel({ onAuth, themeToggle, onBack }) {
|
||||||
|
const [mode, setMode] = useState('login')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!supabase) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
const { data, error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
if (signInError) {
|
||||||
|
setError(signInError.message)
|
||||||
|
} else {
|
||||||
|
setMessage('Erfolgreich angemeldet')
|
||||||
|
onAuth?.(data.session)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
if (signUpError) {
|
||||||
|
setError(signUpError.message)
|
||||||
|
} else if (!data.session) {
|
||||||
|
setMessage('Registrierung abgeschlossen. Prüfe dein Postfach zur Bestätigung.')
|
||||||
|
} else {
|
||||||
|
onAuth?.(data.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-sky-100 via-blue-50 to-emerald-100 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center p-6 text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="absolute top-4 left-4 flex items-center gap-3">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/70 px-3 py-2 text-sm font-medium text-gray-800 shadow-sm backdrop-blur transition hover:-translate-y-[1px] hover:shadow-md dark:border-gray-800 dark:bg-gray-900/70 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-4 right-4">{themeToggle}</div>
|
||||||
|
<div className="w-full max-w-lg bg-white/90 dark:bg-gray-900/80 backdrop-blur rounded-2xl shadow-xl border border-white/70 dark:border-gray-800">
|
||||||
|
<div className="px-8 py-6 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<p className="text-sm uppercase tracking-wide text-indigo-600 dark:text-indigo-300 font-semibold">PAT Stats</p>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">Anmelden oder registrieren</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mt-2">
|
||||||
|
Melde dich mit deinem PAT-Test Konto an, um deine Bewertungen sicher zu verwalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-8 py-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('login')}
|
||||||
|
className={`px-4 py-2 rounded-full transition ${mode === 'login' ? 'bg-indigo-600 text-white' : 'text-gray-600 dark:text-gray-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/40'}`}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('register')}
|
||||||
|
className={`px-4 py-2 rounded-full transition ${mode === 'register' ? 'bg-emerald-600 text-white' : 'text-gray-600 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/40'}`}
|
||||||
|
>
|
||||||
|
Registrieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">E-Mail</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Passwort</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-200 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-200 text-sm">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 rounded-lg font-semibold text-white bg-gradient-to-r from-indigo-600 to-emerald-600 shadow-md hover:shadow-lg transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Wird gesendet...' : mode === 'login' ? 'Einloggen' : 'Konto erstellen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
259
src/components/Landing.jsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
FileDown,
|
||||||
|
ShieldCheck,
|
||||||
|
Smartphone,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react'
|
||||||
|
import PublicLayout from './public/PublicLayout'
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: 'Smarte Auswertungen',
|
||||||
|
body: 'Automatische Berechnungen für jede PAT-Kategorie mit klaren Kennzahlen, Trends und Fortschrittsbildern.',
|
||||||
|
icon: Sparkles
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sichere Speicherung',
|
||||||
|
body: 'Nutzergebundene Datenhaltung, finale Abschlüsse und kontrollierte Freigaben für Trainer, Verein und Staff.',
|
||||||
|
icon: ShieldCheck
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Team-ready',
|
||||||
|
body: 'Bewertungen erfassen, öffnen, teilen und wieder sperren, ohne Excel-Chaos oder doppelte Listen.',
|
||||||
|
icon: Users
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Performance-Insights',
|
||||||
|
body: 'Schwächen erkennen, Entwicklung verfolgen und Training gezielt auf die kritischen Übungen ausrichten.',
|
||||||
|
icon: TrendingUp
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Mobile Erfassung',
|
||||||
|
body: 'Direkte Eingabe am Tisch mit klaren Grenzen pro Übung und sofortiger Berechnung der Punkte.',
|
||||||
|
icon: Smartphone
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Export & Reports',
|
||||||
|
body: 'Saubere PDF-Ausgabe, öffentliche Freigabelinks und strukturierte Ergebnisansichten für Athleten und Staff.',
|
||||||
|
icon: FileDown
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: 'Vorlage wählen',
|
||||||
|
body: 'PAT Start, PAT 1, PAT 2 oder PAT 3 auswählen und die Bewertung sofort anlegen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Werte erfassen',
|
||||||
|
body: 'Einzelwerte direkt eingeben, automatisch begrenzen lassen und live berechnen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Abschließen & teilen',
|
||||||
|
body: 'Bewertung final abschließen, als PDF exportieren oder per Link freigeben.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '100%', label: 'Digitaler Ablauf' },
|
||||||
|
{ value: '0€', label: 'Kostenlos starten' },
|
||||||
|
{ value: 'SSL', label: 'Verschlüsselte Übertragung' },
|
||||||
|
{ value: '24/7', label: 'Jederzeit verfügbar' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const scrollToSection = (sectionId) => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const section = document.getElementById(sectionId)
|
||||||
|
if (!section) return
|
||||||
|
section.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Landing({ onGetStarted, onNavigate, themeToggle }) {
|
||||||
|
return (
|
||||||
|
<PublicLayout
|
||||||
|
themeToggle={themeToggle}
|
||||||
|
onGetStarted={onGetStarted}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
currentPath="/"
|
||||||
|
navLinks={[
|
||||||
|
{ label: 'Features', onClick: () => scrollToSection('features') },
|
||||||
|
{ label: 'So funktioniert es', onClick: () => scrollToSection('how-it-works') },
|
||||||
|
{ label: 'Statistiken', onClick: () => scrollToSection('stats') }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<section className="public-hero">
|
||||||
|
<div>
|
||||||
|
<span className="public-hero__badge">
|
||||||
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Jetzt live verfügbar
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="public-hero__title">
|
||||||
|
Professionelle
|
||||||
|
<span className="public-hero__title-accent"> PAT-Test Verwaltung</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="public-hero__copy">
|
||||||
|
Erfasse, analysiere und verfolge Pool Ability Tests digital. Von der Eingabe bis zum Report läuft alles in
|
||||||
|
einer klaren Oberfläche für Trainer, Vereine und Spieler.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="public-hero__actions">
|
||||||
|
<button type="button" className="public-site__button" onClick={onGetStarted}>
|
||||||
|
Kostenlos testen
|
||||||
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="public-site__button--secondary"
|
||||||
|
onClick={() => scrollToSection('features')}
|
||||||
|
>
|
||||||
|
Mehr erfahren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-hero__meta">
|
||||||
|
<span className="public-chip">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-blue-300" aria-hidden="true" />
|
||||||
|
PAT Start bis PAT 3
|
||||||
|
</span>
|
||||||
|
<span className="public-chip">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-blue-300" aria-hidden="true" />
|
||||||
|
Teilen, PDF und Finalisierung
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-preview__panel">
|
||||||
|
<div className="public-preview__head">
|
||||||
|
<strong>Live Übersicht</strong>
|
||||||
|
<span className="public-preview__status">Online</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-preview__grid">
|
||||||
|
<div className="public-preview__card">
|
||||||
|
<div className="public-preview__label">PAT 1 · Übung 1</div>
|
||||||
|
<div className="public-preview__value public-preview__value--accent">4 / 4</div>
|
||||||
|
<div className="public-progress">
|
||||||
|
<span style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="public-preview__card">
|
||||||
|
<div className="public-preview__label">Gesamtpunktzahl</div>
|
||||||
|
<div className="public-preview__value public-preview__value--success">87%</div>
|
||||||
|
<div className="public-progress">
|
||||||
|
<span style={{ width: '87%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-preview__meta">
|
||||||
|
<div className="public-preview__meta-card">
|
||||||
|
<div className="public-preview__label">Aktive Bewertung</div>
|
||||||
|
<strong>Stefan · PAT 1</strong>
|
||||||
|
</div>
|
||||||
|
<div className="public-preview__meta-card">
|
||||||
|
<div className="public-preview__label">Status</div>
|
||||||
|
<strong>Freigabe aktiv</strong>
|
||||||
|
</div>
|
||||||
|
<div className="public-preview__meta-card">
|
||||||
|
<div className="public-preview__label">Workflow</div>
|
||||||
|
<strong>Erfassen · Finalisieren</strong>
|
||||||
|
</div>
|
||||||
|
<div className="public-preview__meta-card">
|
||||||
|
<div className="public-preview__label">Auswertung</div>
|
||||||
|
<strong>PDF & Analyse</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="public-section" id="features">
|
||||||
|
<div className="public-section__header">
|
||||||
|
<span className="public-section__tag">Funktionen</span>
|
||||||
|
<h2 className="public-section__title">Alles, was du für PAT-Tests brauchst</h2>
|
||||||
|
<p className="public-section__copy">
|
||||||
|
Von der digitalen Erfassung bis zur teamweiten Auswertung. Die neue Startseite orientiert sich an deiner
|
||||||
|
Vorlage, sitzt aber sauber im bestehenden Produktstil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-features">
|
||||||
|
{features.map(({ title, body, icon: Icon }) => (
|
||||||
|
<article key={title} className="public-card">
|
||||||
|
<span className="public-card__icon">
|
||||||
|
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<h3 className="public-card__title">{title}</h3>
|
||||||
|
<p className="public-card__copy">{body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="public-section" id="how-it-works">
|
||||||
|
<div className="public-section__header">
|
||||||
|
<span className="public-section__tag">Prozess</span>
|
||||||
|
<h2 className="public-section__title">So läuft PAT mit uns</h2>
|
||||||
|
<p className="public-section__copy">Von der Testvorlage bis zum Report bleibt der Ablauf kurz, klar und sicher.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-steps">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<article key={step.title} className="public-card public-step">
|
||||||
|
<span className="public-step__number">{index + 1}</span>
|
||||||
|
<h3 className="public-step__title">{step.title}</h3>
|
||||||
|
<p className="public-step__copy">{step.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="public-section" id="stats">
|
||||||
|
<div className="public-section__header">
|
||||||
|
<span className="public-section__tag">Statistiken</span>
|
||||||
|
<h2 className="public-section__title">Klare Vorteile für den Alltag am Tisch</h2>
|
||||||
|
<p className="public-section__copy">
|
||||||
|
Weniger Listenpflege, weniger Medienbrüche und deutlich schneller von der Eingabe zur Auswertung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-stats">
|
||||||
|
{stats.map((item) => (
|
||||||
|
<article key={item.label} className="public-stats__card">
|
||||||
|
<div className="public-stats__value">{item.value}</div>
|
||||||
|
<div className="public-stats__label">{item.label}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="public-section">
|
||||||
|
<div className="public-cta-panel">
|
||||||
|
<div>
|
||||||
|
<span className="public-section__tag">Bereit?</span>
|
||||||
|
<h2 className="public-cta-panel__title">Starte deine nächste PAT-Bewertung ohne Tabellenchaos.</h2>
|
||||||
|
<p className="public-cta-panel__copy">
|
||||||
|
Login öffnen, Bewertung anlegen, Werte eintragen und Ergebnisse direkt sauber speichern oder teilen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-cta-panel__actions">
|
||||||
|
<button type="button" className="public-site__button" onClick={onGetStarted}>
|
||||||
|
Jetzt kostenlos ausprobieren
|
||||||
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="public-site__button--secondary" onClick={() => onNavigate('/kontakt')}>
|
||||||
|
Kontakt öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/components/LiveOverview.jsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { Activity, BarChart3, Clock, TrendingUp } from 'lucide-react'
|
||||||
|
import { calculateTotal } from '../utils/patCalculations'
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const parsed = new Date(value)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveOverview({ assessments, loading = false, error }) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (loading || !assessments) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
average: 0,
|
||||||
|
latest: null,
|
||||||
|
best: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assessments?.length) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
average: 0,
|
||||||
|
latest: null,
|
||||||
|
best: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored = assessments.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
totalPoints: Math.round(calculateTotal(entry.exercises || []))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const average =
|
||||||
|
scored.reduce((sum, item) => sum + (item.totalPoints || 0), 0) / (scored.length || 1)
|
||||||
|
|
||||||
|
const latest = scored[0]
|
||||||
|
const best = scored.reduce((top, current) =>
|
||||||
|
!top || (current.totalPoints || 0) > (top.totalPoints || 0) ? current : top,
|
||||||
|
null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: scored.length,
|
||||||
|
average: Math.round(average),
|
||||||
|
latest,
|
||||||
|
best
|
||||||
|
}
|
||||||
|
}, [assessments])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white/90 dark:bg-gray-900/80 border border-gray-100 dark:border-gray-800 rounded-2xl shadow-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-300">Live Übersicht</p>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">Aktuelle Statistik</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Basis: alle Bewertungen, geladen beim Seitenaufruf</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-emerald-200/70 dark:border-emerald-700 px-3 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-200 bg-emerald-50 dark:bg-emerald-900/40">
|
||||||
|
{loading ? 'Lädt…' : 'Stand: jetzt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[1, 2, 3, 4].map((key) => (
|
||||||
|
<div key={key} className="h-28 rounded-2xl border border-gray-200/60 dark:border-gray-800 bg-gray-100 dark:bg-gray-800 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : stats.total === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/60 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Noch keine Bewertungen vorhanden. Lege eine neue Bewertung an, um hier Live-Werte zu sehen.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-emerald-200/70 dark:border-emerald-800 bg-gradient-to-br from-emerald-50 via-white to-emerald-50 dark:from-emerald-900/30 dark:via-gray-900 dark:to-emerald-900/20 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-200">Bewertungen</p>
|
||||||
|
<Activity className="h-4 w-4 text-emerald-600 dark:text-emerald-300" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-3xl font-bold text-emerald-800 dark:text-emerald-100">{stats.total}</p>
|
||||||
|
<p className="text-xs text-emerald-700/80 dark:text-emerald-200/70">In der PAT Datenbank hinterlegt</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-sky-200/70 dark:border-sky-800 bg-gradient-to-br from-sky-50 via-white to-sky-50 dark:from-sky-900/30 dark:via-gray-900 dark:to-sky-900/20 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-sky-700 dark:text-sky-200">Ø Gesamtpunkte</p>
|
||||||
|
<TrendingUp className="h-4 w-4 text-sky-600 dark:text-sky-300" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-3xl font-bold text-sky-800 dark:text-sky-100">{stats.average}</p>
|
||||||
|
<p className="text-xs text-sky-700/80 dark:text-sky-200/70">Durchschnitt aller Assessments</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-indigo-200/70 dark:border-indigo-800 bg-gradient-to-br from-indigo-50 via-white to-indigo-50 dark:from-indigo-900/30 dark:via-gray-900 dark:to-indigo-900/20 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">Letzte Bewertung</p>
|
||||||
|
<Clock className="h-4 w-4 text-indigo-600 dark:text-indigo-300" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-indigo-900 dark:text-indigo-100">
|
||||||
|
{stats.latest?.name || stats.latest?.patType || 'Unbenannt'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-indigo-700/80 dark:text-indigo-200/70">
|
||||||
|
{formatDate(stats.latest?.datum)} · {stats.latest?.patType || 'PAT'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-amber-200/70 dark:border-amber-800 bg-gradient-to-br from-amber-50 via-white to-amber-50 dark:from-amber-900/30 dark:via-gray-900 dark:to-amber-900/20 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-amber-700 dark:text-amber-200">Top Ergebnis</p>
|
||||||
|
<BarChart3 className="h-4 w-4 text-amber-600 dark:text-amber-300" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-3xl font-bold text-amber-800 dark:text-amber-100">
|
||||||
|
{stats.best?.totalPoints ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700/80 dark:text-amber-200/70">
|
||||||
|
{stats.best?.name || stats.best?.patType || '—'} · {stats.best?.patType || 'PAT'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/components/PatDetail/ExerciseInput.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { normalizeExerciseValue } from '../../utils/exerciseInputRules';
|
||||||
|
|
||||||
|
const inputClassName = (disabled) =>
|
||||||
|
`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded text-center bg-white dark:bg-gray-900 ${
|
||||||
|
disabled ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const ExerciseInput = ({ exercise, exIndex, onChange, disabled = false }) => {
|
||||||
|
const handleInputChange = (valueIndex, subType = null) => (event) => {
|
||||||
|
onChange(exIndex, valueIndex, normalizeExerciseValue(exercise, event.target.value, subType), subType);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exercise.subA && exercise.subB) {
|
||||||
|
const [subLabelA = 'a)', subLabelB = 'b)'] = exercise.subLabels || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-5 gap-2 mb-2">
|
||||||
|
<div></div>
|
||||||
|
<div className="font-semibold">{subLabelA}</div>
|
||||||
|
{exercise.subA.map((val, i) => (
|
||||||
|
<input
|
||||||
|
key={`a-${i}`}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="off"
|
||||||
|
value={val}
|
||||||
|
onChange={handleInputChange(i, 'a')}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName(disabled)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2 mb-2">
|
||||||
|
<div></div>
|
||||||
|
<div className="font-semibold">{subLabelB}</div>
|
||||||
|
{exercise.subB.map((val, i) => (
|
||||||
|
<input
|
||||||
|
key={`b-${i}`}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="off"
|
||||||
|
value={val}
|
||||||
|
onChange={handleInputChange(i, 'b')}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName(disabled)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridCols =
|
||||||
|
exercise.values.length === 4 ? 'grid-cols-6' : exercise.values.length === 5 ? 'grid-cols-7' : 'grid-cols-5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid ${gridCols} gap-2 mb-2`}>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
{exercise.values.map((val, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="off"
|
||||||
|
value={val}
|
||||||
|
onChange={handleInputChange(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName(disabled)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExerciseInput;
|
||||||
227
src/components/PatDetail/PatDetail.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
|
||||||
|
import ExerciseInput from './ExerciseInput';
|
||||||
|
import SaveDialog from './SaveDialog';
|
||||||
|
import { downloadAssessmentPdf } from '../../utils/pdfExport';
|
||||||
|
|
||||||
|
const PatDetail = ({
|
||||||
|
currentAssessment,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
onBack,
|
||||||
|
onSave,
|
||||||
|
onAssessmentChange,
|
||||||
|
onUpdateExercise,
|
||||||
|
onMarkDirty,
|
||||||
|
showSaveDialog,
|
||||||
|
onSaveAndExit,
|
||||||
|
onDiscardAndExit,
|
||||||
|
onCancelExit,
|
||||||
|
getPatTypeColor,
|
||||||
|
getAchievement,
|
||||||
|
calculateTotal,
|
||||||
|
isReadOnly = false,
|
||||||
|
canFinalize = false,
|
||||||
|
finalizeDisabledReason = '',
|
||||||
|
onFinalize,
|
||||||
|
isShareActive = false,
|
||||||
|
canShare = false,
|
||||||
|
shareDisabledReason = '',
|
||||||
|
onShare,
|
||||||
|
onRevokeShare
|
||||||
|
}) => {
|
||||||
|
if (!currentAssessment) return null;
|
||||||
|
|
||||||
|
const totalPoints = calculateTotal();
|
||||||
|
const achievement = getAchievement(currentAssessment?.patType, totalPoints);
|
||||||
|
const handleDownloadPdf = async () => {
|
||||||
|
await downloadAssessmentPdf({
|
||||||
|
assessment: currentAssessment,
|
||||||
|
totalPoints,
|
||||||
|
achievement
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6 text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<SaveDialog
|
||||||
|
show={showSaveDialog}
|
||||||
|
onSave={onSaveAndExit}
|
||||||
|
onDiscard={onDiscardAndExit}
|
||||||
|
onCancel={onCancelExit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 font-medium"
|
||||||
|
>
|
||||||
|
← Zurück zur Übersicht
|
||||||
|
</button>
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<span className="text-orange-500 dark:text-orange-300 text-sm font-semibold">
|
||||||
|
● Ungespeicherte Änderungen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-semibold ${getPatTypeColor(
|
||||||
|
currentAssessment?.patType
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{currentAssessment?.patType}
|
||||||
|
</span>
|
||||||
|
{isShareActive && (
|
||||||
|
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
|
||||||
|
Freigabe aktiv
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{currentAssessment?.isFinalized && (
|
||||||
|
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
|
||||||
|
Final abgeschlossen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" aria-hidden="true" />
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onFinalize}
|
||||||
|
disabled={!canFinalize}
|
||||||
|
title={finalizeDisabledReason || 'Test final abschließen'}
|
||||||
|
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
|
||||||
|
canFinalize
|
||||||
|
? 'bg-emerald-700 text-white hover:bg-emerald-800'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
Final Abschließen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
className="bg-amber-600 text-white px-4 py-2 rounded-lg hover:bg-amber-700 transition flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4" aria-hidden="true" />
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onShare}
|
||||||
|
disabled={!canShare}
|
||||||
|
title={shareDisabledReason || 'Test als Link teilen'}
|
||||||
|
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
|
||||||
|
canShare
|
||||||
|
? 'bg-sky-600 text-white hover:bg-sky-700'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
Teilen
|
||||||
|
</button>
|
||||||
|
{isShareActive && (
|
||||||
|
<button
|
||||||
|
onClick={onRevokeShare}
|
||||||
|
className="border border-rose-300 text-rose-700 dark:border-rose-700 dark:text-rose-300 px-4 py-2 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-950/40 transition text-sm"
|
||||||
|
>
|
||||||
|
Freigabe aufheben
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="mb-6 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||||
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
|
<Lock className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Nur Lese-Modus
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
Diese Bewertung wurde final abgeschlossen und kann nicht mehr bearbeitet werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded">
|
||||||
|
<div className="font-bold">Datum / Name</div>
|
||||||
|
<div></div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={currentAssessment?.datum || ''}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
onAssessmentChange({ ...currentAssessment, datum: e.target.value });
|
||||||
|
onMarkDirty();
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-900 ${
|
||||||
|
isReadOnly ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentAssessment?.name || ''}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
onAssessmentChange({ ...currentAssessment, name: e.target.value });
|
||||||
|
onMarkDirty();
|
||||||
|
}}
|
||||||
|
placeholder="Name"
|
||||||
|
className={`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-900 ${
|
||||||
|
isReadOnly ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentAssessment?.exercises.map((exercise, exIndex) => (
|
||||||
|
<div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{exercise.name}</div>
|
||||||
|
|
||||||
|
<ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-2 mb-1 bg-blue-50 dark:bg-blue-900/30 p-2 rounded">
|
||||||
|
<div className="font-semibold">Durchschnitt Soll {exercise.soll}</div>
|
||||||
|
<div className="font-semibold">Ist</div>
|
||||||
|
<div className="font-bold text-center">{exercise.durchschnitt.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-2 mb-1">
|
||||||
|
<div>Faktor</div>
|
||||||
|
<div></div>
|
||||||
|
<div className="text-center font-semibold">{exercise.faktor}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-2 bg-green-50 dark:bg-green-900/30 p-2 rounded">
|
||||||
|
<div className="font-bold">Points</div>
|
||||||
|
<div></div>
|
||||||
|
<div className="text-center font-bold text-green-600 dark:text-green-300">{exercise.points.toFixed(0)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-6 bg-gradient-to-r from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 p-6 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">Ergebnis</span>
|
||||||
|
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
|
||||||
|
<p className="text-2xl font-bold">{achievement.name}</p>
|
||||||
|
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PatDetail;
|
||||||
36
src/components/PatDetail/SaveDialog.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SaveDialog = ({ show, onSave, onDiscard, onCancel }) => {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4 border border-gray-200 dark:border-gray-800">
|
||||||
|
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4">Ungespeicherte Änderungen</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-6">Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern?</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="flex-1 bg-green-600 text-white px-4 py-3 rounded-lg hover:bg-green-700 transition font-semibold"
|
||||||
|
>
|
||||||
|
Ja, speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDiscard}
|
||||||
|
className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition font-semibold"
|
||||||
|
>
|
||||||
|
Nein, verwerfen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 bg-gray-500 dark:bg-gray-700 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-600 transition font-semibold"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SaveDialog;
|
||||||
526
src/components/PatList/PatList.jsx
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
User
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const CALENDAR_DAY_LABELS = ['M', 'D', 'M', 'D', 'F', 'S', 'S'];
|
||||||
|
|
||||||
|
const toIsoDate = (date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseIsoDate = (value) => {
|
||||||
|
if (!value || !value.includes('-')) return null;
|
||||||
|
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCalendarDays = (monthDate) => {
|
||||||
|
const startOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
|
||||||
|
const offset = (startOfMonth.getDay() + 6) % 7;
|
||||||
|
const calendarStart = new Date(startOfMonth.getFullYear(), startOfMonth.getMonth(), 1 - offset);
|
||||||
|
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const day = new Date(calendarStart.getFullYear(), calendarStart.getMonth(), calendarStart.getDate() + index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: toIsoDate(day),
|
||||||
|
date: day,
|
||||||
|
isoValue: toIsoDate(day),
|
||||||
|
isCurrentMonth: day.getMonth() === monthDate.getMonth()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const PatList = ({
|
||||||
|
patTypes,
|
||||||
|
assessments,
|
||||||
|
overview,
|
||||||
|
assessmentShareTokens = {},
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
getPatTypeColor,
|
||||||
|
getAchievement
|
||||||
|
}) => {
|
||||||
|
const datePickerRef = useRef(null);
|
||||||
|
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||||
|
const [activeDatePicker, setActiveDatePicker] = useState(null);
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: 'datum', direction: 'desc' });
|
||||||
|
const [filters, setFilters] = useState({ name: '', datum: '' });
|
||||||
|
const [visibleMonth, setVisibleMonth] = useState(() => {
|
||||||
|
const today = new Date();
|
||||||
|
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const patTypeOptions = Object.keys(patTypes || {});
|
||||||
|
const openAssessments = assessments.filter((assessment) => !assessment.isFinalized);
|
||||||
|
const finalizedAssessments = assessments.filter((assessment) => assessment.isFinalized);
|
||||||
|
|
||||||
|
const getResultValue = (assessment) =>
|
||||||
|
assessment.exercises.reduce((sum, ex) => sum + (ex.points || 0), 0);
|
||||||
|
|
||||||
|
const normalize = (value) => String(value || '').toLowerCase().trim();
|
||||||
|
const formatDateForFilter = (value) => {
|
||||||
|
if (!value || !value.includes('-')) return '';
|
||||||
|
const [year, month, day] = value.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
};
|
||||||
|
const formatDateForRow = (value) => {
|
||||||
|
if (!value || !value.includes('-')) return value || '';
|
||||||
|
const [year, month, day] = value.split('-');
|
||||||
|
return `${year}.${month}.${day}`;
|
||||||
|
};
|
||||||
|
const monthLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat('de-DE', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(visibleMonth),
|
||||||
|
[visibleMonth]
|
||||||
|
);
|
||||||
|
const calendarDays = useMemo(() => getCalendarDays(visibleMonth), [visibleMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeDatePicker) return undefined;
|
||||||
|
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (datePickerRef.current && !datePickerRef.current.contains(event.target)) {
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [activeDatePicker]);
|
||||||
|
|
||||||
|
const matchesFilters = (assessment) => {
|
||||||
|
const matchesName = !filters.name || normalize(assessment.name).includes(normalize(filters.name));
|
||||||
|
const matchesDate = !filters.datum || normalize(assessment.datum).includes(normalize(filters.datum));
|
||||||
|
|
||||||
|
return matchesName && matchesDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortValue = (assessment, key) => {
|
||||||
|
if (key === 'name') return (assessment.name || '').toLowerCase();
|
||||||
|
if (key === 'datum') return assessment.datum || '';
|
||||||
|
if (key === 'result') return getResultValue(assessment);
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortAssessments = (entries) =>
|
||||||
|
[...entries].sort((left, right) => {
|
||||||
|
const leftValue = getSortValue(left, sortConfig.key);
|
||||||
|
const rightValue = getSortValue(right, sortConfig.key);
|
||||||
|
|
||||||
|
if (leftValue < rightValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (leftValue > rightValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedOpenAssessments = useMemo(
|
||||||
|
() => sortAssessments(openAssessments.filter(matchesFilters)),
|
||||||
|
[openAssessments, sortConfig, filters]
|
||||||
|
);
|
||||||
|
const sortedFinalizedAssessments = useMemo(
|
||||||
|
() => sortAssessments(finalizedAssessments.filter(matchesFilters)),
|
||||||
|
[finalizedAssessments, sortConfig, filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
setSortConfig((current) =>
|
||||||
|
current.key === key
|
||||||
|
? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||||
|
: { key, direction: key === 'datum' ? 'desc' : 'asc' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSortIcon = (key) => {
|
||||||
|
if (sortConfig.key !== key) {
|
||||||
|
return <ArrowUpDown className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortConfig.direction === 'asc' ? (
|
||||||
|
<ChevronUp className="w-4 h-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" aria-hidden="true" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncVisibleMonth = (value) => {
|
||||||
|
const parsedDate = parseIsoDate(value);
|
||||||
|
const baseDate = parsedDate || new Date();
|
||||||
|
setVisibleMonth(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDatePicker = (tableKey) => {
|
||||||
|
setActiveDatePicker((current) => {
|
||||||
|
if (current !== tableKey) {
|
||||||
|
syncVisibleMonth(filters.datum);
|
||||||
|
return tableKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectDate = (value) => {
|
||||||
|
handleFilterChange('datum', value);
|
||||||
|
syncVisibleMonth(value);
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDate = () => {
|
||||||
|
handleFilterChange('datum', '');
|
||||||
|
syncVisibleMonth('');
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectToday = () => {
|
||||||
|
selectDate(toIsoDate(new Date()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAssessmentTable = (tableKey, title, entries, totalEntries, emptyMessage) => {
|
||||||
|
const isDatePickerOpen = activeDatePicker === tableKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/70">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Anzahl: {entries.length}
|
||||||
|
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalEntries === 0 ? (
|
||||||
|
<div className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">{emptyMessage}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-800 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
{renderSortIcon('name')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filters.name}
|
||||||
|
onChange={(event) => handleFilterChange('name', event.target.value)}
|
||||||
|
aria-label="Nach Name filtern"
|
||||||
|
className="w-40 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-sm font-normal text-gray-700 dark:text-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort('datum')}
|
||||||
|
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
|
||||||
|
>
|
||||||
|
Datum
|
||||||
|
{renderSortIcon('datum')}
|
||||||
|
</button>
|
||||||
|
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDatePicker(tableKey)}
|
||||||
|
aria-label="Nach Datum filtern"
|
||||||
|
aria-expanded={isDatePickerOpen}
|
||||||
|
className="flex w-36 items-center justify-between rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-left text-sm font-normal text-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<span>{formatDateForFilter(filters.datum) || 'tt.mm.jjjj'}</span>
|
||||||
|
<Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDatePickerOpen && (
|
||||||
|
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-xl border border-gray-200 bg-white p-3 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleMonth(
|
||||||
|
(current) => new Date(current.getFullYear(), current.getMonth() - 1, 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label="Vorheriger Monat"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold capitalize text-gray-800 dark:text-gray-100">
|
||||||
|
{monthLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleMonth(
|
||||||
|
(current) => new Date(current.getFullYear(), current.getMonth() + 1, 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label="Nächster Monat"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{CALENDAR_DAY_LABELS.map((label, index) => (
|
||||||
|
<span key={`${label}-${index}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-7 gap-1">
|
||||||
|
{calendarDays.map((day) => {
|
||||||
|
const isSelected = day.isoValue === filters.datum;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectDate(day.isoValue)}
|
||||||
|
className={`flex h-10 items-center justify-center rounded-lg text-sm transition ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: day.isCurrentMonth
|
||||||
|
? 'text-gray-800 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800'
|
||||||
|
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-600 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.date.getDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectToday}
|
||||||
|
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearDate}
|
||||||
|
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort('result')}
|
||||||
|
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
|
||||||
|
>
|
||||||
|
Ergebnis
|
||||||
|
{renderSortIcon('result')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200 text-right"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<tr className="border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<td colSpan="4" className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : entries.map((assessment) => {
|
||||||
|
const shareToken = assessmentShareTokens[assessment.id];
|
||||||
|
const hasShare = Boolean(shareToken);
|
||||||
|
const totalPoints = getResultValue(assessment);
|
||||||
|
const achievement = getAchievement(assessment.patType, totalPoints);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={assessment.id}
|
||||||
|
className="border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{assessment.name}</span>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold ${getPatTypeColor(
|
||||||
|
assessment.patType
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{assessment.patType}
|
||||||
|
</span>
|
||||||
|
{hasShare && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
|
||||||
|
Freigabe aktiv
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{assessment.isFinalized && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
|
||||||
|
Final abgeschlossen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{formatDateForRow(assessment.datum)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl font-bold text-green-600 dark:text-green-300">
|
||||||
|
{totalPoints.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex px-2 py-1 rounded text-xs font-semibold ${achievement.color} ${achievement.text}`}>
|
||||||
|
{achievement.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(assessment)}
|
||||||
|
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm"
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(assessment.id);
|
||||||
|
}}
|
||||||
|
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-2"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-green-50 to-blue-100 dark:from-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 mb-6 border border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">PAT Test Manager</h1>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateMenu((current) => !current)}
|
||||||
|
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Neue Bewertung
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCreateMenu && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg z-10 overflow-hidden">
|
||||||
|
{patTypeOptions.map((patType) => (
|
||||||
|
<button
|
||||||
|
key={patType}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateMenu(false);
|
||||||
|
onCreate(patType);
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-3 text-left text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||||
|
>
|
||||||
|
{patType}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overview && <div className="mt-6">{overview}</div>}
|
||||||
|
|
||||||
|
{assessments.length > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{renderAssessmentTable(
|
||||||
|
'open',
|
||||||
|
'Noch offene Bewertungen',
|
||||||
|
sortedOpenAssessments,
|
||||||
|
openAssessments.length,
|
||||||
|
'Keine offenen Bewertungen mit den aktuellen Suchfiltern gefunden'
|
||||||
|
)}
|
||||||
|
{renderAssessmentTable(
|
||||||
|
'finalized',
|
||||||
|
'Abgeschlossene Bewertungen',
|
||||||
|
sortedFinalizedAssessments,
|
||||||
|
finalizedAssessments.length,
|
||||||
|
'Keine abgeschlossenen Bewertungen mit den aktuellen Suchfiltern gefunden'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assessments.length === 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-12 text-center border border-gray-100 dark:border-gray-800">
|
||||||
|
<User className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-700" aria-hidden="true" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">Noch keine Bewertungen vorhanden</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Lege oben eine neue Bewertung an und wähle dabei PAT Start, PAT 1, PAT 2 oder PAT 3</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PatList;
|
||||||
317
src/components/PatTestManager.jsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import PatList from './PatList/PatList';
|
||||||
|
import PatDetail from './PatDetail/PatDetail';
|
||||||
|
import AnalysisTab from './Analysis/AnalysisTab';
|
||||||
|
import { patTypes } from '../data/patTypes';
|
||||||
|
import { useAssessments } from '../hooks/useAssessments';
|
||||||
|
import { getAchievement, getPatTypeColor } from '../utils/patCalculations';
|
||||||
|
import LiveOverview from './LiveOverview';
|
||||||
|
import { buildAnalysis } from '../utils/analysisEngine';
|
||||||
|
import { generateTrainingPlan } from '../utils/trainingPlanGenerator';
|
||||||
|
import { saveTrainingPlanWithSessions } from '../lib/trainingPlanService';
|
||||||
|
import {
|
||||||
|
createAssessmentShareLink,
|
||||||
|
listAssessmentShares,
|
||||||
|
revokeAssessmentShare
|
||||||
|
} from '../lib/assessmentShareService';
|
||||||
|
import { getFinalizeDisabledReason } from '../utils/assessmentState';
|
||||||
|
|
||||||
|
const copyToClipboard = async (value) => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const isLocalhostShare = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||||||
|
|
||||||
|
export default function PatTestManager({
|
||||||
|
user,
|
||||||
|
activeTab = 'assessments',
|
||||||
|
onEditingStateChange
|
||||||
|
}) {
|
||||||
|
const visiblePatTypes = useMemo(() => patTypes || {}, []);
|
||||||
|
const patTypeOptions = Object.keys(visiblePatTypes);
|
||||||
|
const [showList, setShowList] = useState(true);
|
||||||
|
const [selectedPatType, setSelectedPatType] = useState(() => patTypeOptions[0] || '');
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
const [assessmentShareTokens, setAssessmentShareTokens] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEditingStateChange?.(!showList);
|
||||||
|
}, [onEditingStateChange, showList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!patTypeOptions.length) return;
|
||||||
|
if (!selectedPatType || !patTypeOptions.includes(selectedPatType)) {
|
||||||
|
setSelectedPatType(patTypeOptions[0]);
|
||||||
|
}
|
||||||
|
}, [patTypeOptions, selectedPatType]);
|
||||||
|
|
||||||
|
const regenerateTrainingPlan = useCallback(async ({ savedAssessment, assessments: nextAssessments }) => {
|
||||||
|
if (!savedAssessment?.patType || !nextAssessments?.length) return;
|
||||||
|
|
||||||
|
const analysis = buildAnalysis(nextAssessments, savedAssessment.patType, {
|
||||||
|
windowSize: 5,
|
||||||
|
missingStrategy: 'zero'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!analysis.windowedAssessments.length) return;
|
||||||
|
|
||||||
|
const generated = generateTrainingPlan({
|
||||||
|
topWeaknesses: analysis.topWeaknesses,
|
||||||
|
topStrengths: analysis.topStrengths,
|
||||||
|
durationWeeks: 4,
|
||||||
|
sessionsPerWeek: 3,
|
||||||
|
sessionMix: '1-main-2-secondary'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await saveTrainingPlanWithSessions({
|
||||||
|
patType: savedAssessment.patType,
|
||||||
|
analysisSnapshot: analysis,
|
||||||
|
durationWeeks: 4,
|
||||||
|
sessionsPerWeek: 3,
|
||||||
|
sessions: generated.sessions
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn('Automatische Plan-Neugenerierung fehlgeschlagen:', result.error?.message || result.error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
assessments,
|
||||||
|
currentAssessment,
|
||||||
|
setCurrentAssessment,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
createNewAssessment,
|
||||||
|
openAssessment,
|
||||||
|
handleSave,
|
||||||
|
finalizeAssessment,
|
||||||
|
deleteAssessment,
|
||||||
|
updateExerciseValue,
|
||||||
|
calculateTotal
|
||||||
|
} = useAssessments(visiblePatTypes, user, {
|
||||||
|
onAfterSave: regenerateTrainingPlan
|
||||||
|
});
|
||||||
|
const visibleAssessments = assessments;
|
||||||
|
const visibleAssessmentIds = useMemo(
|
||||||
|
() => visibleAssessments.map((assessment) => assessment.id),
|
||||||
|
[visibleAssessments]
|
||||||
|
);
|
||||||
|
const isCurrentAssessmentPersisted = useMemo(
|
||||||
|
() => assessments.some((assessment) => assessment.id === currentAssessment?.id),
|
||||||
|
[assessments, currentAssessment?.id]
|
||||||
|
);
|
||||||
|
const isCurrentAssessmentShareActive = Boolean(
|
||||||
|
currentAssessment?.id && assessmentShareTokens[currentAssessment.id]
|
||||||
|
);
|
||||||
|
const isCurrentAssessmentReadOnly = Boolean(currentAssessment?.isFinalized);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
const loadAssessmentShares = async () => {
|
||||||
|
if (!user?.id || !visibleAssessmentIds.length) {
|
||||||
|
setAssessmentShareTokens({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shareTokens = await listAssessmentShares(visibleAssessmentIds);
|
||||||
|
if (!ignore) {
|
||||||
|
setAssessmentShareTokens(shareTokens);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Freigaben konnten nicht geladen werden:', error);
|
||||||
|
if (!ignore) {
|
||||||
|
setAssessmentShareTokens({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAssessmentShares();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [user?.id, visibleAssessmentIds]);
|
||||||
|
|
||||||
|
const handleBackToList = () => {
|
||||||
|
if (hasUnsavedChanges && !isCurrentAssessmentReadOnly) {
|
||||||
|
setShowSaveDialog(true);
|
||||||
|
} else {
|
||||||
|
setShowList(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeSave = async () => {
|
||||||
|
const result = await handleSave();
|
||||||
|
if (result?.ok) {
|
||||||
|
alert('Erfolgreich gespeichert!');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalize = useCallback(async () => {
|
||||||
|
const result = await finalizeAssessment();
|
||||||
|
if (result?.ok) {
|
||||||
|
alert('Bewertung wurde final abgeschlossen und ist jetzt nur noch lesbar.');
|
||||||
|
}
|
||||||
|
}, [finalizeAssessment]);
|
||||||
|
|
||||||
|
const handleSaveAndExit = async () => {
|
||||||
|
if (!currentAssessment?.name || !currentAssessment?.datum) {
|
||||||
|
alert('Bitte Name und Datum ausfüllen bevor Sie speichern');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeSave();
|
||||||
|
if (result?.ok) {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
setShowList(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscardAndExit = () => {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setShowList(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelExit = () => {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
};
|
||||||
|
const handleShare = useCallback(async (assessment) => {
|
||||||
|
try {
|
||||||
|
const { token, url } = await createAssessmentShareLink(assessment);
|
||||||
|
const label = assessment?.name || assessment?.patType || 'dieser Test';
|
||||||
|
const localhostHint = isLocalhostShare()
|
||||||
|
? '\n\nHinweis: Der Link zeigt aktuell auf localhost und ist nur erreichbar, wenn die App von außen erreichbar ist.'
|
||||||
|
: '';
|
||||||
|
setAssessmentShareTokens((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[assessment.id]: token
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copied = await copyToClipboard(url);
|
||||||
|
if (copied) {
|
||||||
|
alert(`Freigabelink für "${label}" wurde in die Zwischenablage kopiert.${localhostHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (clipboardError) {
|
||||||
|
console.warn('Kopieren in die Zwischenablage fehlgeschlagen:', clipboardError);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.prompt(`Freigabelink für "${label}"${localhostHint}`, url);
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Freigabelink konnte nicht erstellt werden: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const handleRevokeShare = useCallback(async (assessment) => {
|
||||||
|
const label = assessment?.name || assessment?.patType || 'dieser Test';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revokeAssessmentShare(assessment.id);
|
||||||
|
setAssessmentShareTokens((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[assessment.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
alert(`Freigabe für "${label}" wurde aufgehoben.`);
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Freigabe konnte nicht aufgehoben werden: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const handleShareFromDetail = useCallback(() => {
|
||||||
|
if (!currentAssessment) return;
|
||||||
|
|
||||||
|
if (!isCurrentAssessmentPersisted) {
|
||||||
|
alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
alert('Bitte erst speichern, damit der aktuelle Stand geteilt wird.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleShare(currentAssessment);
|
||||||
|
}, [currentAssessment, handleShare, hasUnsavedChanges, isCurrentAssessmentPersisted]);
|
||||||
|
const handleRevokeShareFromDetail = useCallback(() => {
|
||||||
|
if (!currentAssessment || !isCurrentAssessmentShareActive) return;
|
||||||
|
handleRevokeShare(currentAssessment);
|
||||||
|
}, [currentAssessment, handleRevokeShare, isCurrentAssessmentShareActive]);
|
||||||
|
const shareDisabledReason = !isCurrentAssessmentPersisted
|
||||||
|
? 'Bitte zuerst speichern'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird'
|
||||||
|
: '';
|
||||||
|
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);
|
||||||
|
|
||||||
|
if (!showList) {
|
||||||
|
return (
|
||||||
|
<PatDetail
|
||||||
|
currentAssessment={currentAssessment}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
onBack={handleBackToList}
|
||||||
|
onSave={executeSave}
|
||||||
|
onAssessmentChange={setCurrentAssessment}
|
||||||
|
onUpdateExercise={updateExerciseValue}
|
||||||
|
onMarkDirty={() => setHasUnsavedChanges(true)}
|
||||||
|
showSaveDialog={showSaveDialog}
|
||||||
|
onSaveAndExit={handleSaveAndExit}
|
||||||
|
onDiscardAndExit={handleDiscardAndExit}
|
||||||
|
onCancelExit={handleCancelExit}
|
||||||
|
getPatTypeColor={getPatTypeColor}
|
||||||
|
getAchievement={getAchievement}
|
||||||
|
calculateTotal={calculateTotal}
|
||||||
|
isReadOnly={isCurrentAssessmentReadOnly}
|
||||||
|
canFinalize={!finalizeDisabledReason}
|
||||||
|
finalizeDisabledReason={finalizeDisabledReason}
|
||||||
|
onFinalize={handleFinalize}
|
||||||
|
isShareActive={isCurrentAssessmentShareActive}
|
||||||
|
canShare={isCurrentAssessmentPersisted && !hasUnsavedChanges}
|
||||||
|
shareDisabledReason={shareDisabledReason}
|
||||||
|
onShare={handleShareFromDetail}
|
||||||
|
onRevokeShare={handleRevokeShareFromDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'analysis') {
|
||||||
|
return (
|
||||||
|
<AnalysisTab
|
||||||
|
assessments={visibleAssessments}
|
||||||
|
selectedPatType={selectedPatType}
|
||||||
|
onPatTypeChange={setSelectedPatType}
|
||||||
|
patTypes={visiblePatTypes}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PatList
|
||||||
|
patTypes={visiblePatTypes}
|
||||||
|
assessments={visibleAssessments}
|
||||||
|
overview={<LiveOverview assessments={visibleAssessments} />}
|
||||||
|
assessmentShareTokens={assessmentShareTokens}
|
||||||
|
selectedPatType={selectedPatType}
|
||||||
|
onPatTypeChange={setSelectedPatType}
|
||||||
|
onCreate={(patType) => {
|
||||||
|
createNewAssessment(patType);
|
||||||
|
setShowList(false);
|
||||||
|
}}
|
||||||
|
onEdit={(assessment) => {
|
||||||
|
openAssessment(assessment);
|
||||||
|
setShowList(false);
|
||||||
|
}}
|
||||||
|
onDelete={deleteAssessment}
|
||||||
|
getPatTypeColor={getPatTypeColor}
|
||||||
|
getAchievement={getAchievement}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/SharedAssessmentPage.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import SharedAssessmentView from './SharedAssessmentView'
|
||||||
|
import { getSharedAssessment } from '../lib/assessmentShareService'
|
||||||
|
|
||||||
|
export default function SharedAssessmentPage({ shareToken, themeToggle }) {
|
||||||
|
const [assessment, setAssessment] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false
|
||||||
|
|
||||||
|
const loadAssessment = async () => {
|
||||||
|
if (!shareToken) {
|
||||||
|
setError('Freigabelink fehlt.')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sharedAssessment = await getSharedAssessment(shareToken)
|
||||||
|
|
||||||
|
if (ignore) return
|
||||||
|
|
||||||
|
if (!sharedAssessment) {
|
||||||
|
setAssessment(null)
|
||||||
|
setError('Dieser Freigabelink ist ungültig oder nicht mehr verfügbar.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssessment(sharedAssessment)
|
||||||
|
} catch (err) {
|
||||||
|
if (ignore) return
|
||||||
|
setAssessment(null)
|
||||||
|
setError(err.message || 'Geteilter Test konnte nicht geladen werden.')
|
||||||
|
} finally {
|
||||||
|
if (!ignore) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAssessment()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true
|
||||||
|
}
|
||||||
|
}, [shareToken])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="absolute top-4 right-4 z-20">{themeToggle}</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-6">
|
||||||
|
Geteilter Test wird geladen...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-xl w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-rose-600 dark:text-rose-300">Freigabe</p>
|
||||||
|
<h1 className="text-2xl font-bold mt-2">Geteilter Test nicht verfügbar</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mt-3">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SharedAssessmentView assessment={assessment} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
src/components/SharedAssessmentView.jsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { calculateTotal, getAchievement, getPatTypeColor } from '../utils/patCalculations'
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const parsed = new Date(value)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = (value) => {
|
||||||
|
if (value === '' || value === null || typeof value === 'undefined') return '—'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValueRow = ({ label, values = [] }) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="min-w-10 text-sm font-semibold text-gray-600 dark:text-gray-300">{label}</span>
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<span
|
||||||
|
key={`${label}-${index}`}
|
||||||
|
className="min-w-12 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-center text-sm font-medium text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{formatValue(value)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function SharedAssessmentView({ assessment }) {
|
||||||
|
if (!assessment) return null
|
||||||
|
|
||||||
|
const totalPoints = calculateTotal(assessment.exercises || [])
|
||||||
|
const achievement = getAchievement(assessment.patType, totalPoints)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6 text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex justify-between items-start gap-4 mb-6 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-sky-700 dark:text-sky-300">Geteilter Test</p>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mt-2">
|
||||||
|
{assessment.name || 'PAT Test'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||||
|
Dieser Link zeigt nur diesen einen Test im Nur-Lesen-Modus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-semibold ${getPatTypeColor(assessment.patType)}`}
|
||||||
|
>
|
||||||
|
{assessment.patType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-3 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold">{formatDate(assessment.datum)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Gesamtpunkte</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-green-600 dark:text-green-300">
|
||||||
|
{totalPoints.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewertung</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold">{achievement.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(assessment.exercises || []).map((exercise, index) => (
|
||||||
|
<div key={`${exercise.name}-${index}`} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-lg text-gray-700 dark:text-gray-200">{exercise.name}</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Soll {exercise.soll} · Faktor {exercise.faktor}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-green-50 dark:bg-green-900/30 px-4 py-2">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-green-700 dark:text-green-300">Points</p>
|
||||||
|
<p className="text-xl font-bold text-green-700 dark:text-green-200">
|
||||||
|
{(exercise.points || 0).toFixed(0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{exercise.subA && exercise.subB ? (
|
||||||
|
<>
|
||||||
|
<ValueRow label={exercise.subLabels?.[0] || 'a)'} values={exercise.subA} />
|
||||||
|
<ValueRow label={exercise.subLabels?.[1] || 'b)'} values={exercise.subB} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ValueRow label="Werte" values={exercise.values || []} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-3 mt-4">
|
||||||
|
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/30 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-blue-700 dark:text-blue-300">Durchschnitt</p>
|
||||||
|
<p className="text-lg font-semibold text-blue-700 dark:text-blue-200">
|
||||||
|
{(exercise.durchschnitt || 0).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Soll</p>
|
||||||
|
<p className="text-lg font-semibold">{exercise.soll}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Faktor</p>
|
||||||
|
<p className="text-lg font-semibold">{exercise.faktor}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-6 bg-gradient-to-r from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 p-6 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center gap-4 flex-wrap mb-4">
|
||||||
|
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">Ergebnis</span>
|
||||||
|
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
|
||||||
|
<p className="text-2xl font-bold">{achievement.name}</p>
|
||||||
|
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
211
src/components/public/PublicInfoPage.jsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ArrowRight, FileText, Mail, ShieldCheck } from 'lucide-react'
|
||||||
|
import PublicLayout from './PublicLayout'
|
||||||
|
|
||||||
|
const pageContent = {
|
||||||
|
'/datenschutz': {
|
||||||
|
tag: 'Datenschutz',
|
||||||
|
title: 'Datenschutzhinweise für PAT Manager',
|
||||||
|
intro:
|
||||||
|
'Diese Seite ist als saubere öffentliche Datenschutzansicht angelegt. Inhaltlich solltest du sie vor dem Livegang noch mit deinen finalen Verantwortlichen, Kontaktdaten und juristisch geprüften Angaben vervollständigen.',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: 'Verantwortliche Stelle',
|
||||||
|
body:
|
||||||
|
'Trage hier den Vereinsnamen, die verantwortliche Person oder das Unternehmen ein, das PAT Manager betreibt. Ergänze außerdem Anschrift und eine echte Kontaktmöglichkeit.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Welche Daten verarbeitet werden',
|
||||||
|
body:
|
||||||
|
'Je nach Nutzung können Anmeldedaten, Bewertungsdaten, Freigaben, PDF-Exporte und technische Zugriffsdaten verarbeitet werden. Bei geteilten Bewertungen sehen anonyme Nutzer nur die explizit freigegebene Ansicht.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zweck der Verarbeitung',
|
||||||
|
body:
|
||||||
|
'Die Verarbeitung dient der Durchführung von PAT-Bewertungen, der Auswertung von Leistungsdaten, der Teamorganisation sowie dem sicheren Speichern und Teilen einzelner Testergebnisse.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Speicherung und Dienstleister',
|
||||||
|
body:
|
||||||
|
'Die Anwendung nutzt Supabase als technische Datenbasis. Prüfe vor dem produktiven Einsatz die Auftragsverarbeitung, Speicherregion und die tatsächlich eingesetzten Zusatzdienste.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rechte der betroffenen Personen',
|
||||||
|
items: [
|
||||||
|
'Auskunft über gespeicherte Daten',
|
||||||
|
'Berichtigung unrichtiger Daten',
|
||||||
|
'Löschung oder Einschränkung der Verarbeitung',
|
||||||
|
'Widerspruch gegen einzelne Verarbeitungen',
|
||||||
|
'Beschwerde bei der zuständigen Aufsichtsbehörde'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Wichtiger Hinweis',
|
||||||
|
wide: true,
|
||||||
|
notice:
|
||||||
|
'Diese Datenschutzseite ist eine strukturierte Vorlage. Vor der Veröffentlichung müssen echte Kontaktdaten, Rechtsgrundlagen, Speicherfristen und Dienstleisterangaben ergänzt und geprüft werden.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'/impressum': {
|
||||||
|
tag: 'Impressum',
|
||||||
|
title: 'Impressum für PAT Manager',
|
||||||
|
intro:
|
||||||
|
'Die Seite ist technisch fertig eingebunden, damit deine Footer- und Navigationslinks nicht mehr ins Leere laufen. Die konkreten Pflichtangaben musst du jetzt noch mit deinen echten Daten ergänzen.',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: 'Angaben gemäß § 5 TMG',
|
||||||
|
body:
|
||||||
|
'Ergänze hier Betreibername, Rechtsform, vertretungsberechtigte Person sowie die vollständige ladungsfähige Anschrift.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kontakt',
|
||||||
|
body:
|
||||||
|
'Trage hier eine echte E-Mail-Adresse, optional Telefonnummer und weitere Kommunikationswege ein, unter denen Nutzer dich rechtssicher erreichen können.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Verantwortlich für den Inhalt',
|
||||||
|
body:
|
||||||
|
'Wenn erforderlich, ergänze die verantwortliche Person nach § 18 Abs. 2 MStV mit Name und Anschrift.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Haftungs- und Linkhinweise',
|
||||||
|
body:
|
||||||
|
'Wenn du externe Dienste, Inhalte oder Verlinkungen nutzt, prüfe bitte deine endgültigen Texte zu Haftung, Urheberrecht und externen Verweisen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Wichtiger Hinweis',
|
||||||
|
wide: true,
|
||||||
|
notice:
|
||||||
|
'Dieses Impressum enthält absichtlich keine erfundenen Unternehmensdaten. Bitte ersetze die Platzhalter vor Veröffentlichung durch deine echten Pflichtangaben.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'/kontakt': {
|
||||||
|
tag: 'Kontakt',
|
||||||
|
title: 'Kontakt und Rückfragen',
|
||||||
|
intro:
|
||||||
|
'Hier ist eine öffentliche Kontaktseite im Stil der neuen Startseite eingebunden. Wenn du reale Ansprechpartner und Kanäle ergänzt, kannst du sie direkt produktiv nutzen.',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: 'Produkt & Demo',
|
||||||
|
body:
|
||||||
|
'Nutze diesen Bereich für Anfragen zu PAT Manager, Demos, Vereinsnutzung oder Einführung im Trainingsalltag.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Technischer Support',
|
||||||
|
body:
|
||||||
|
'Hier kannst du später Support-Zeiten, E-Mail-Adresse, Reaktionszeiten oder einen Helpdesk-Link ergänzen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kooperationen',
|
||||||
|
body:
|
||||||
|
'Platz für Kontakte zu Vereinen, Trainern, Leistungszentren oder Partnern, die das System einsetzen oder testen möchten.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Empfohlene nächste Ergänzungen',
|
||||||
|
items: [
|
||||||
|
'Echte Support-E-Mail oder Kontaktformular verknüpfen',
|
||||||
|
'Antwortzeiten und Zuständigkeiten ergänzen',
|
||||||
|
'Optional Telefonnummer oder Vereinsanschrift angeben'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Schnellzugriff',
|
||||||
|
wide: true,
|
||||||
|
action: true,
|
||||||
|
body:
|
||||||
|
'Wenn du direkt in die Anwendung wechseln willst, kannst du von hier aus sofort den Login öffnen und weiterarbeiten.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicInfoPage({ path, onGetStarted, onNavigate, themeToggle }) {
|
||||||
|
const content = pageContent[path] || pageContent['/kontakt']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout
|
||||||
|
themeToggle={themeToggle}
|
||||||
|
onGetStarted={onGetStarted}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
currentPath={path}
|
||||||
|
navLinks={[
|
||||||
|
{ label: 'Startseite', onClick: () => onNavigate('/') },
|
||||||
|
{ label: 'Datenschutz', onClick: () => onNavigate('/datenschutz'), active: path === '/datenschutz' },
|
||||||
|
{ label: 'Impressum', onClick: () => onNavigate('/impressum'), active: path === '/impressum' },
|
||||||
|
{ label: 'Kontakt', onClick: () => onNavigate('/kontakt'), active: path === '/kontakt' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<section className="public-page">
|
||||||
|
<div className="public-page__hero">
|
||||||
|
<span className="public-section__tag">
|
||||||
|
{path === '/kontakt' ? <Mail className="h-4 w-4" aria-hidden="true" /> : <FileText className="h-4 w-4" aria-hidden="true" />}
|
||||||
|
{content.tag}
|
||||||
|
</span>
|
||||||
|
<h1 className="public-page__title">{content.title}</h1>
|
||||||
|
<p className="public-page__copy">{content.intro}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="public-page__grid">
|
||||||
|
{content.cards.map((card) => (
|
||||||
|
<article
|
||||||
|
key={card.title}
|
||||||
|
className={`public-page__card${card.wide ? ' public-page__card--wide' : ''}`}
|
||||||
|
>
|
||||||
|
<h2 className="public-page__card-title">{card.title}</h2>
|
||||||
|
|
||||||
|
{card.body && <p className="public-page__card-copy">{card.body}</p>}
|
||||||
|
|
||||||
|
{card.items && (
|
||||||
|
<ul className="public-page__list">
|
||||||
|
{card.items.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{card.notice && <div className="public-page__notice">{card.notice}</div>}
|
||||||
|
|
||||||
|
{card.action && (
|
||||||
|
<div className="public-hero__actions">
|
||||||
|
<button type="button" className="public-site__button" onClick={onGetStarted}>
|
||||||
|
Login öffnen
|
||||||
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="public-site__button--secondary" onClick={() => onNavigate('/')}>
|
||||||
|
Zur Startseite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="public-section">
|
||||||
|
<div className="public-cta-panel">
|
||||||
|
<div>
|
||||||
|
<span className="public-section__tag">
|
||||||
|
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Öffentliche Seiten fertig
|
||||||
|
</span>
|
||||||
|
<h2 className="public-cta-panel__title">Die verlinkten Info-Seiten sind jetzt Bestandteil der App.</h2>
|
||||||
|
<p className="public-cta-panel__copy">
|
||||||
|
Ergänze jetzt noch deine echten Vereins-, Kontakt- und Rechtstexte. Danach laufen Navigation und Footer
|
||||||
|
sauber ohne Platzhalterlinks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="public-cta-panel__actions">
|
||||||
|
<button type="button" className="public-site__button" onClick={() => onNavigate('/')}>
|
||||||
|
Zur Startseite
|
||||||
|
</button>
|
||||||
|
<button type="button" className="public-site__button--secondary" onClick={onGetStarted}>
|
||||||
|
App öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
src/components/public/PublicLayout.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ArrowRight, Target } from 'lucide-react'
|
||||||
|
import './publicSite.css'
|
||||||
|
|
||||||
|
const footerPages = [
|
||||||
|
{ label: 'Datenschutz', path: '/datenschutz' },
|
||||||
|
{ label: 'Impressum', path: '/impressum' },
|
||||||
|
{ label: 'Kontakt', path: '/kontakt' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function PublicLayout({
|
||||||
|
children,
|
||||||
|
themeToggle,
|
||||||
|
onGetStarted,
|
||||||
|
onNavigate,
|
||||||
|
navLinks = [],
|
||||||
|
currentPath = '/'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="public-site">
|
||||||
|
<div className="public-site__backdrop" />
|
||||||
|
<div className="public-site__grid" />
|
||||||
|
|
||||||
|
<header className="public-site__nav">
|
||||||
|
<button type="button" className="public-site__brand" onClick={() => onNavigate('/')}>
|
||||||
|
<span className="public-site__brand-mark">
|
||||||
|
<Target className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="public-site__brand-copy">
|
||||||
|
<span className="public-site__brand-title">PAT Manager</span>
|
||||||
|
<span className="public-site__brand-subtitle">Digitale Leistungsdiagnostik für Billard</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav className="public-site__nav-links" aria-label="Öffentliche Navigation">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.label}
|
||||||
|
type="button"
|
||||||
|
className={`public-site__nav-link${link.active ? ' is-active' : ''}`}
|
||||||
|
onClick={link.onClick}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="public-site__nav-actions">
|
||||||
|
{themeToggle && <div className="public-site__theme-toggle">{themeToggle}</div>}
|
||||||
|
<button type="button" className="public-site__cta public-site__cta--nav" onClick={onGetStarted}>
|
||||||
|
Anmelden
|
||||||
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="public-site__main">{children}</main>
|
||||||
|
|
||||||
|
<footer className="public-site__footer">
|
||||||
|
<div className="public-site__footer-inner">
|
||||||
|
<div className="public-site__footer-links">
|
||||||
|
<button type="button" className="public-site__footer-link" onClick={onGetStarted}>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
{footerPages.map((page) => (
|
||||||
|
<button
|
||||||
|
key={page.path}
|
||||||
|
type="button"
|
||||||
|
className={`public-site__footer-link${currentPath === page.path ? ' is-active' : ''}`}
|
||||||
|
onClick={() => onNavigate(page.path)}
|
||||||
|
>
|
||||||
|
{page.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="public-site__footer-meta">PAT Manager · Öffentliche Seiten</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
772
src/components/public/publicSite.css
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
.public-site {
|
||||||
|
--public-bg: #07131f;
|
||||||
|
--public-bg-strong: #0b1726;
|
||||||
|
--public-surface: rgba(11, 23, 38, 0.78);
|
||||||
|
--public-surface-soft: rgba(15, 27, 45, 0.62);
|
||||||
|
--public-border: rgba(148, 163, 184, 0.16);
|
||||||
|
--public-text: #f8fafc;
|
||||||
|
--public-muted: #9db0c8;
|
||||||
|
--public-accent: #60a5fa;
|
||||||
|
--public-accent-strong: #2563eb;
|
||||||
|
--public-success: #3b82f6;
|
||||||
|
--public-warm: #f59e0b;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--public-text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.16), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 22%),
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(245, 158, 11, 0.08), transparent 30%),
|
||||||
|
linear-gradient(180deg, var(--public-bg) 0%, var(--public-bg-strong) 48%, var(--public-bg) 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__backdrop,
|
||||||
|
.public-site__grid {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__backdrop {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 20%),
|
||||||
|
radial-gradient(circle at 82% 22%, rgba(96, 165, 250, 0.14), transparent 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 52px 52px;
|
||||||
|
mask-image: linear-gradient(180deg, transparent, black 18%, black 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.2rem 5vw;
|
||||||
|
background: rgba(7, 19, 31, 0.84);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--public-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__brand-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(135deg, rgba(96, 165, 250, 0.96), rgba(37, 99, 235, 0.92));
|
||||||
|
color: #eff6ff;
|
||||||
|
box-shadow: 0 16px 40px rgba(14, 165, 233, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__brand-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__brand-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__brand-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--public-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__nav-links,
|
||||||
|
.public-site__footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__nav-link,
|
||||||
|
.public-site__footer-link,
|
||||||
|
.public-site__text-action {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__nav-link:hover,
|
||||||
|
.public-site__footer-link:hover,
|
||||||
|
.public-site__text-action:hover,
|
||||||
|
.public-site__nav-link.is-active,
|
||||||
|
.public-site__footer-link.is-active {
|
||||||
|
color: var(--public-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__cta,
|
||||||
|
.public-site__button,
|
||||||
|
.public-site__button--secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
padding: 0.95rem 1.4rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.22s ease, box-shadow 0.22s ease, background 0.22s ease, border-color 0.22s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__cta,
|
||||||
|
.public-site__button {
|
||||||
|
color: #eff6ff;
|
||||||
|
background: linear-gradient(135deg, var(--public-accent), var(--public-accent-strong));
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__cta:hover,
|
||||||
|
.public-site__button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 18px 38px rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__cta--nav {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__cta--nav:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__button--secondary {
|
||||||
|
color: var(--public-text);
|
||||||
|
background: rgba(15, 27, 45, 0.55);
|
||||||
|
border: 1px solid var(--public-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__button--secondary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(56, 189, 248, 0.32);
|
||||||
|
background: rgba(15, 27, 45, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0 5vw 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero,
|
||||||
|
.public-section,
|
||||||
|
.public-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
|
||||||
|
gap: 3rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4.8rem 0 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__badge,
|
||||||
|
.public-section__tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.22);
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__title {
|
||||||
|
margin: 1.35rem 0 1.1rem;
|
||||||
|
font-size: clamp(2.85rem, 6vw, 5rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__title-accent {
|
||||||
|
display: block;
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(135deg, #dbeafe 0%, #60a5fa 45%, #2563eb 100%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__copy {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 40rem;
|
||||||
|
color: var(--public-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.95rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--public-border);
|
||||||
|
background: rgba(15, 27, 45, 0.62);
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
color: var(--public-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__panel,
|
||||||
|
.public-card,
|
||||||
|
.public-page__card,
|
||||||
|
.public-stats__card,
|
||||||
|
.public-preview__panel {
|
||||||
|
border: 1px solid var(--public-border);
|
||||||
|
background: var(--public-surface);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__panel {
|
||||||
|
padding: 1.6rem;
|
||||||
|
border-radius: 1.8rem;
|
||||||
|
transform: perspective(1000px) rotateY(-6deg) rotateX(4deg);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__panel:hover {
|
||||||
|
transform: perspective(1000px) rotateY(-2deg) rotateX(1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__status {
|
||||||
|
color: #bfdbfe;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__status::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
margin-right: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 6px rgba(96, 165, 250, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__card {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding: 1.15rem;
|
||||||
|
background: rgba(7, 19, 31, 0.56);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__label {
|
||||||
|
color: var(--public-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__value {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__value--accent {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__value--success {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-progress {
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-progress > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--public-accent), var(--public-accent-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__meta-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(3, 9, 18, 0.42);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-section {
|
||||||
|
margin-bottom: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-section__header {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-section__title {
|
||||||
|
margin: 1rem 0 0.75rem;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.25rem);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-section__copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-features,
|
||||||
|
.public-steps,
|
||||||
|
.public-page__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-features {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-card {
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-card__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(37, 99, 235, 0.16));
|
||||||
|
color: #dbeafe;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-card__title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-card__copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-steps {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-step {
|
||||||
|
position: relative;
|
||||||
|
padding: 1.6rem;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-step__number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 3.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(96, 165, 250, 0.12);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.32);
|
||||||
|
color: #dbeafe;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-step__title {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-step__copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-stats__card {
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-stats__value {
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-stats__label {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: var(--public-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-cta-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 1.9rem;
|
||||||
|
background: linear-gradient(135deg, rgba(6, 18, 31, 0.94), rgba(11, 23, 38, 0.78));
|
||||||
|
border: 1px solid var(--public-border);
|
||||||
|
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-cta-panel__title {
|
||||||
|
margin: 0.75rem 0 0.55rem;
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-cta-panel__copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-cta-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page {
|
||||||
|
padding: 3.25rem 0 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__hero {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__title {
|
||||||
|
margin: 1rem 0 0.8rem;
|
||||||
|
font-size: clamp(2.25rem, 5vw, 4rem);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
font-size: 1.03rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__card {
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__card--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__card-title {
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__card-copy,
|
||||||
|
.public-page__list {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--public-muted);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__list {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__list li + li {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page__notice {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.18);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
color: #fcd34d;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 5vw 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__footer-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__footer-meta {
|
||||||
|
color: var(--public-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site {
|
||||||
|
--public-bg: #eef4ff;
|
||||||
|
--public-bg-strong: #f8fbff;
|
||||||
|
--public-surface: rgba(255, 255, 255, 0.88);
|
||||||
|
--public-surface-soft: rgba(255, 255, 255, 0.74);
|
||||||
|
--public-border: rgba(37, 99, 235, 0.14);
|
||||||
|
--public-text: #0f172a;
|
||||||
|
--public-muted: #475569;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(96, 165, 250, 0.2), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(191, 219, 254, 0.44), transparent 24%),
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(245, 158, 11, 0.12), transparent 30%),
|
||||||
|
linear-gradient(180deg, var(--public-bg) 0%, var(--public-bg-strong) 48%, #edf5ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__backdrop {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(96, 165, 250, 0.14), transparent 22%),
|
||||||
|
radial-gradient(circle at 82% 22%, rgba(59, 130, 246, 0.12), transparent 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(37, 99, 235, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(37, 99, 235, 0.05) 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__nav {
|
||||||
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
border-bottom: 1px solid rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__brand-mark {
|
||||||
|
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__button--secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
border-color: rgba(37, 99, 235, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__button--secondary:hover {
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-hero__badge,
|
||||||
|
html:not(.dark) .public-section__tag {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: rgba(96, 165, 250, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-chip {
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-preview__panel,
|
||||||
|
html:not(.dark) .public-card,
|
||||||
|
html:not(.dark) .public-page__card,
|
||||||
|
html:not(.dark) .public-stats__card {
|
||||||
|
box-shadow: 0 24px 52px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-preview__card {
|
||||||
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
border-color: rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-progress {
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-preview__meta-card {
|
||||||
|
background: rgba(244, 247, 255, 0.9);
|
||||||
|
border-color: rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-cta-panel {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(239, 246, 255, 0.92));
|
||||||
|
box-shadow: 0 24px 52px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .public-site__footer-inner {
|
||||||
|
border-top: 1px solid rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.public-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__panel {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-features,
|
||||||
|
.public-steps,
|
||||||
|
.public-stats,
|
||||||
|
.public-page__grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-cta-panel {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.public-site__nav {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__nav-links,
|
||||||
|
.public-site__nav-actions,
|
||||||
|
.public-site__footer-inner {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-site__footer-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview__grid,
|
||||||
|
.public-preview__meta,
|
||||||
|
.public-features,
|
||||||
|
.public-steps,
|
||||||
|
.public-stats,
|
||||||
|
.public-page__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero__title {
|
||||||
|
font-size: clamp(2.4rem, 12vw, 4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/data/patTypes.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export const patTypes = {
|
||||||
|
'PAT Start': [
|
||||||
|
{ name: '1) Stoß-Geschwindigkeit', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '2) Stoß-Gradlinigkeit', values: ['', '', '', ''], soll: 2, durchschnitt: 0, faktor: 50.0, points: 0 },
|
||||||
|
{ name: '3) Winkelbälle', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '4) Nachlauf Wirkung', values: ['', '', ''], soll: 9, durchschnitt: 0, faktor: 11.11, points: 0 },
|
||||||
|
{ name: '5) Rücklauf-Wirkung', values: ['', '', ''], soll: 7, durchschnitt: 0, faktor: 14.29, points: 0 },
|
||||||
|
{ name: '6) Kl. Positions-Spiel', subA: ['', '', ''], subB: ['', '', ''], soll: 4, durchschnitt: 0, faktor: 25, points: 0 },
|
||||||
|
{ name: '7) Gr. Pos. Bereich', subA: ['', '', ''], subB: ['', '', ''], soll: 5, durchschnitt: 0, faktor: 20, points: 0 },
|
||||||
|
{ name: '8) Press Bande', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '9) Standardbälle', values: ['', '', '', '', ''], soll: 2, durchschnitt: 0, faktor: 50.0, points: 0 },
|
||||||
|
{ name: '10) 8-Ball Situation', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 }
|
||||||
|
],
|
||||||
|
'PAT 1': [
|
||||||
|
{ name: '1) Geschwindigkeit', values: ['', '', ''], soll: 3.33, durchschnitt: 0, faktor: 30.0, points: 0, minValue: 0, maxValue: 4 },
|
||||||
|
{ name: '2) Geradlinigkeit (Speed 2)', values: ['', '', '', ''], soll: 2.5, durchschnitt: 0, faktor: 40.0, points: 0, minValue: 0, maxValue: 3 },
|
||||||
|
{ name: '3) Nachlauf', values: ['', '', ''], soll: 11.0, durchschnitt: 0, faktor: 9.1, points: 0, minValue: 0, maxValue: 12 },
|
||||||
|
{ name: '4) Rücklauf', values: ['', '', ''], soll: 10.0, durchschnitt: 0, faktor: 10.0, points: 0, minValue: 0, maxValue: 12 },
|
||||||
|
{
|
||||||
|
name: '5) Kl. Pos.-Bereich',
|
||||||
|
subA: ['', '', ''],
|
||||||
|
subB: ['', '', ''],
|
||||||
|
subLabels: ['1', '2'],
|
||||||
|
soll: 3.33,
|
||||||
|
durchschnitt: 0,
|
||||||
|
faktor: 30.0,
|
||||||
|
points: 0,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '6) Gr. Pos.-Bereich',
|
||||||
|
subA: ['', '', ''],
|
||||||
|
subB: ['', '', ''],
|
||||||
|
subLabels: ['1', '2'],
|
||||||
|
soll: 3.33,
|
||||||
|
durchschnitt: 0,
|
||||||
|
faktor: 30.0,
|
||||||
|
points: 0,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '7) Press Bande',
|
||||||
|
subA: ['', '', ''],
|
||||||
|
subB: ['', '', ''],
|
||||||
|
subLabels: ['1', '2'],
|
||||||
|
soll: 4.0,
|
||||||
|
durchschnitt: 0,
|
||||||
|
faktor: 25.0,
|
||||||
|
points: 0,
|
||||||
|
subValueRanges: {
|
||||||
|
a: { minValue: 0, maxValue: 6 },
|
||||||
|
b: { minValue: 0, maxValue: 10 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ name: '8) Endlos-Übung (Max: 9)', values: ['', '', ''], soll: 4.5, durchschnitt: 0, faktor: 22.22, points: 0, minValue: 0, maxValue: 9 },
|
||||||
|
{ name: '9) Standardbälle', values: ['', '', '', '', ''], soll: 2.0, durchschnitt: 0, faktor: 50.0, points: 0, minValue: 0, maxValue: 3 },
|
||||||
|
{ name: '10) 9-Ball Situation 5', values: ['', '', ''], soll: 4.5, durchschnitt: 0, faktor: 22.22, points: 0, minValue: 0, maxValue: 9 }
|
||||||
|
],
|
||||||
|
'PAT 2': [
|
||||||
|
{ name: '1) Übung PAT 2-1', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '2) Übung PAT 2-2', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '3) Übung PAT 2-3', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 }
|
||||||
|
],
|
||||||
|
'PAT 3': [
|
||||||
|
{ name: '1) Übung PAT 3-1', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '2) Übung PAT 3-2', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
|
||||||
|
{ name: '3) Übung PAT 3-3', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 }
|
||||||
|
]
|
||||||
|
};
|
||||||
238
src/hooks/useAssessments.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { calculateExercise, calculateTotal } from '../utils/patCalculations';
|
||||||
|
import { supabase } from '../lib/supabaseClient';
|
||||||
|
import { normalizeExerciseValue } from '../utils/exerciseInputRules';
|
||||||
|
import {
|
||||||
|
cloneAssessment,
|
||||||
|
getFinalizeDisabledReason,
|
||||||
|
isAssessmentComplete
|
||||||
|
} from '../utils/assessmentState';
|
||||||
|
|
||||||
|
const generateId = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString());
|
||||||
|
|
||||||
|
const mapFromDb = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
patType: row.pat_type,
|
||||||
|
datum: row.datum,
|
||||||
|
name: row.name,
|
||||||
|
exercises: cloneAssessment(row.exercises || []),
|
||||||
|
isFinalized: Boolean(row.is_finalized),
|
||||||
|
finalizedAt: row.finalized_at || null,
|
||||||
|
finalizationReminderSentAt: row.finalization_reminder_sent_at || null
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapToDb = (assessment, userId) => ({
|
||||||
|
id: assessment.id,
|
||||||
|
user_id: userId,
|
||||||
|
pat_type: assessment.patType,
|
||||||
|
datum: assessment.datum,
|
||||||
|
name: assessment.name,
|
||||||
|
exercises: cloneAssessment(assessment.exercises),
|
||||||
|
is_finalized: Boolean(assessment.isFinalized),
|
||||||
|
finalized_at: assessment.finalizedAt || null,
|
||||||
|
finalization_reminder_sent_at: assessment.finalizationReminderSentAt || null
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAssessments = (patTypes, user, options = {}) => {
|
||||||
|
const { onAfterSave } = options;
|
||||||
|
const [assessments, setAssessments] = useState([]);
|
||||||
|
const [currentAssessment, setCurrentAssessment] = useState(null);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
|
const canUseSupabase = useMemo(() => !!supabase && !!user?.id, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canUseSupabase) return;
|
||||||
|
loadAssessments();
|
||||||
|
}, [canUseSupabase]);
|
||||||
|
|
||||||
|
const loadAssessments = async () => {
|
||||||
|
if (!canUseSupabase) return;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assessments')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
setAssessments((data || []).map(mapFromDb));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden:', error);
|
||||||
|
alert('Fehler beim Laden der Daten aus Supabase: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewAssessment = (patType) => {
|
||||||
|
const newAssessment = {
|
||||||
|
id: generateId(),
|
||||||
|
patType: patType,
|
||||||
|
datum: new Date().toISOString().split('T')[0],
|
||||||
|
name: '',
|
||||||
|
exercises: cloneAssessment(patTypes[patType]),
|
||||||
|
isFinalized: false,
|
||||||
|
finalizedAt: null,
|
||||||
|
finalizationReminderSentAt: null
|
||||||
|
};
|
||||||
|
setCurrentAssessment(newAssessment);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssessment = (assessment) => {
|
||||||
|
setCurrentAssessment(cloneAssessment(assessment));
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistAssessment = async ({ finalize = false } = {}) => {
|
||||||
|
if (!currentAssessment?.name || !currentAssessment?.datum) {
|
||||||
|
alert('Bitte Name und Datum ausfüllen');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAssessment?.isFinalized && !finalize) {
|
||||||
|
alert('Final abgeschlossene Bewertungen können nicht mehr geändert werden.');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalize) {
|
||||||
|
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);
|
||||||
|
if (finalizeDisabledReason || !isAssessmentComplete(currentAssessment)) {
|
||||||
|
alert(finalizeDisabledReason || 'Bitte alle Felder ausfüllen, bevor du final abschließt.');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canUseSupabase) {
|
||||||
|
alert('Supabase ist nicht konfiguriert. Bitte .env prüfen.');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = assessments.find((a) => a.id === currentAssessment.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextAssessment = cloneAssessment(currentAssessment);
|
||||||
|
nextAssessment.name = nextAssessment.name.trim();
|
||||||
|
|
||||||
|
if (finalize) {
|
||||||
|
nextAssessment.isFinalized = true;
|
||||||
|
nextAssessment.finalizedAt = new Date().toISOString();
|
||||||
|
nextAssessment.finalizationReminderSentAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = mapToDb(nextAssessment, user.id);
|
||||||
|
let saved = null;
|
||||||
|
let nextAssessments = [];
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assessments')
|
||||||
|
.insert(payload)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
saved = mapFromDb(data);
|
||||||
|
nextAssessments = [saved, ...assessments.filter((item) => item.id !== saved.id)];
|
||||||
|
setAssessments(nextAssessments);
|
||||||
|
setCurrentAssessment(saved);
|
||||||
|
} else {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assessments')
|
||||||
|
.update(payload)
|
||||||
|
.eq('id', currentAssessment.id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
saved = mapFromDb(data);
|
||||||
|
nextAssessments = assessments.map((item) => (item.id === saved.id ? saved : item));
|
||||||
|
setAssessments(nextAssessments);
|
||||||
|
setCurrentAssessment(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onAfterSave === 'function') {
|
||||||
|
try {
|
||||||
|
await onAfterSave({
|
||||||
|
savedAssessment: saved,
|
||||||
|
assessments: nextAssessments
|
||||||
|
});
|
||||||
|
} catch (postSaveError) {
|
||||||
|
console.warn('Post-save hook failed:', postSaveError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
assessment: saved,
|
||||||
|
assessments: nextAssessments
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern in Supabase: ' + error.message);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => persistAssessment();
|
||||||
|
|
||||||
|
const finalizeAssessment = async () => persistAssessment({ finalize: true });
|
||||||
|
|
||||||
|
const updateExerciseValue = (exerciseIndex, valueIndex, newValue, subType = null) => {
|
||||||
|
if (!currentAssessment || currentAssessment.isFinalized) return;
|
||||||
|
|
||||||
|
const updated = cloneAssessment(currentAssessment);
|
||||||
|
const normalizedValue = normalizeExerciseValue(updated.exercises[exerciseIndex], newValue, subType);
|
||||||
|
|
||||||
|
if (subType === 'a') {
|
||||||
|
updated.exercises[exerciseIndex].subA[valueIndex] = normalizedValue;
|
||||||
|
} else if (subType === 'b') {
|
||||||
|
updated.exercises[exerciseIndex].subB[valueIndex] = normalizedValue;
|
||||||
|
} else {
|
||||||
|
updated.exercises[exerciseIndex].values[valueIndex] = normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { durchschnitt, points } = calculateExercise(updated.exercises[exerciseIndex]);
|
||||||
|
updated.exercises[exerciseIndex].durchschnitt = durchschnitt;
|
||||||
|
updated.exercises[exerciseIndex].points = points;
|
||||||
|
|
||||||
|
setCurrentAssessment(updated);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
const deleteAssessment = async (id) => {
|
||||||
|
if (!confirm('Möchten Sie diese Bewertung wirklich löschen?')) return;
|
||||||
|
if (!canUseSupabase) {
|
||||||
|
alert('Supabase ist nicht konfiguriert. Bitte .env prüfen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from('assessments').delete().eq('id', id).eq('user_id', user.id);
|
||||||
|
if (error) {
|
||||||
|
alert('Fehler beim Löschen: ' + error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssessments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
assessments,
|
||||||
|
currentAssessment,
|
||||||
|
setCurrentAssessment,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
createNewAssessment,
|
||||||
|
openAssessment,
|
||||||
|
handleSave,
|
||||||
|
finalizeAssessment,
|
||||||
|
deleteAssessment,
|
||||||
|
updateExerciseValue,
|
||||||
|
calculateTotal: () => calculateTotal(currentAssessment?.exercises || [])
|
||||||
|
};
|
||||||
|
};
|
||||||
275
src/hooks/useTrainingPlans.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { supabase } from '../lib/supabaseClient';
|
||||||
|
import { saveTrainingPlanWithSessions } from '../lib/trainingPlanService';
|
||||||
|
import { generateTrainingPlan } from '../utils/trainingPlanGenerator';
|
||||||
|
|
||||||
|
const mapPlanRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
patType: row.pat_type,
|
||||||
|
status: row.status,
|
||||||
|
analysisSnapshot: row.analysis_snapshot || {},
|
||||||
|
durationWeeks: row.duration_weeks,
|
||||||
|
sessionsPerWeek: row.sessions_per_week,
|
||||||
|
generatedAt: row.generated_at,
|
||||||
|
archivedAt: row.archived_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
sessions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapSessionRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
planId: row.plan_id,
|
||||||
|
weekNo: row.week_no,
|
||||||
|
sessionNo: row.session_no,
|
||||||
|
mainExercise: row.main_exercise,
|
||||||
|
secondaryExercises: row.secondary_exercises || [],
|
||||||
|
tasks: row.tasks || [],
|
||||||
|
state: row.state || 'open',
|
||||||
|
notes: row.notes || '',
|
||||||
|
completedAt: row.completed_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSessionInPlan = (plan, sessionId, patch) => {
|
||||||
|
if (!plan) return plan;
|
||||||
|
|
||||||
|
const hasSession = plan.sessions.some((session) => session.id === sessionId);
|
||||||
|
if (!hasSession) return plan;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
sessions: plan.sessions.map((session) =>
|
||||||
|
session.id === sessionId
|
||||||
|
? {
|
||||||
|
...session,
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
: session
|
||||||
|
)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTrainingPlans = ({ user, patType }) => {
|
||||||
|
const [activePlan, setActivePlan] = useState(null);
|
||||||
|
const [historyPlans, setHistoryPlans] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const canUseSupabase = useMemo(() => Boolean(supabase && user?.id), [user]);
|
||||||
|
|
||||||
|
const loadPlans = useCallback(async () => {
|
||||||
|
if (!canUseSupabase || !patType) {
|
||||||
|
setActivePlan(null);
|
||||||
|
setHistoryPlans([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: plansData, error: plansError } = await supabase
|
||||||
|
.from('training_plans')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('pat_type', patType)
|
||||||
|
.order('generated_at', { ascending: false });
|
||||||
|
|
||||||
|
if (plansError) throw plansError;
|
||||||
|
|
||||||
|
const mappedPlans = (plansData || []).map(mapPlanRow);
|
||||||
|
const planIds = mappedPlans.map((plan) => plan.id);
|
||||||
|
|
||||||
|
let sessionsByPlan = new Map();
|
||||||
|
|
||||||
|
if (planIds.length) {
|
||||||
|
const { data: sessionsData, error: sessionsError } = await supabase
|
||||||
|
.from('training_plan_sessions')
|
||||||
|
.select('*')
|
||||||
|
.in('plan_id', planIds)
|
||||||
|
.order('week_no', { ascending: true })
|
||||||
|
.order('session_no', { ascending: true });
|
||||||
|
|
||||||
|
if (sessionsError) throw sessionsError;
|
||||||
|
|
||||||
|
sessionsByPlan = (sessionsData || []).reduce((map, row) => {
|
||||||
|
const session = mapSessionRow(row);
|
||||||
|
const list = map.get(session.planId) || [];
|
||||||
|
list.push(session);
|
||||||
|
map.set(session.planId, list);
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const plansWithSessions = mappedPlans.map((plan) => ({
|
||||||
|
...plan,
|
||||||
|
sessions: sessionsByPlan.get(plan.id) || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const active = plansWithSessions.find((plan) => plan.status === 'active') || plansWithSessions[0] || null;
|
||||||
|
const history = plansWithSessions.filter((plan) => !active || plan.id !== active.id);
|
||||||
|
|
||||||
|
setActivePlan(active);
|
||||||
|
setHistoryPlans(history);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError.message || 'Fehler beim Laden der Trainingspläne.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canUseSupabase, patType, user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPlans();
|
||||||
|
}, [loadPlans]);
|
||||||
|
|
||||||
|
const saveGeneratedPlan = useCallback(
|
||||||
|
async ({ analysis, durationWeeks = 4, sessionsPerWeek = 3, sessions = null }) => {
|
||||||
|
if (!canUseSupabase || !patType) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error('Supabase oder PAT-Typ nicht verfügbar.')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preparedSessions = sessions
|
||||||
|
? sessions
|
||||||
|
: generateTrainingPlan({
|
||||||
|
topWeaknesses: analysis?.topWeaknesses || [],
|
||||||
|
topStrengths: analysis?.topStrengths || [],
|
||||||
|
durationWeeks,
|
||||||
|
sessionsPerWeek
|
||||||
|
}).sessions;
|
||||||
|
|
||||||
|
const result = await saveTrainingPlanWithSessions({
|
||||||
|
patType,
|
||||||
|
analysisSnapshot: analysis,
|
||||||
|
durationWeeks,
|
||||||
|
sessionsPerWeek,
|
||||||
|
sessions: preparedSessions
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
|
await loadPlans();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
planId: result.planId
|
||||||
|
};
|
||||||
|
} catch (saveError) {
|
||||||
|
setError(saveError.message || 'Fehler beim Speichern des Trainingsplans.');
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: saveError
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canUseSupabase, loadPlans, patType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSessionState = useCallback(async (sessionId, nextState) => {
|
||||||
|
if (!canUseSupabase) return { ok: false };
|
||||||
|
|
||||||
|
const nextCompletedAt = nextState === 'done' ? new Date().toISOString() : null;
|
||||||
|
|
||||||
|
const prevActive = activePlan;
|
||||||
|
const prevHistory = historyPlans;
|
||||||
|
|
||||||
|
setActivePlan((current) => updateSessionInPlan(current, sessionId, {
|
||||||
|
state: nextState,
|
||||||
|
completedAt: nextCompletedAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
setHistoryPlans((current) =>
|
||||||
|
current.map((plan) =>
|
||||||
|
updateSessionInPlan(plan, sessionId, {
|
||||||
|
state: nextState,
|
||||||
|
completedAt: nextCompletedAt
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('training_plan_sessions')
|
||||||
|
.update({
|
||||||
|
state: nextState,
|
||||||
|
completed_at: nextCompletedAt
|
||||||
|
})
|
||||||
|
.eq('id', sessionId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
setActivePlan(prevActive);
|
||||||
|
setHistoryPlans(prevHistory);
|
||||||
|
setError(updateError.message || 'Session-Status konnte nicht gespeichert werden.');
|
||||||
|
return { ok: false, error: updateError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}, [activePlan, canUseSupabase, historyPlans]);
|
||||||
|
|
||||||
|
const updateSessionPartial = useCallback(async (sessionId, patch) => {
|
||||||
|
if (!canUseSupabase) return { ok: false };
|
||||||
|
|
||||||
|
const normalizedPatch = {
|
||||||
|
...patch
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevActive = activePlan;
|
||||||
|
const prevHistory = historyPlans;
|
||||||
|
|
||||||
|
setActivePlan((current) => updateSessionInPlan(current, sessionId, normalizedPatch));
|
||||||
|
setHistoryPlans((current) => current.map((plan) => updateSessionInPlan(plan, sessionId, normalizedPatch)));
|
||||||
|
|
||||||
|
const dbPatch = {};
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'mainExercise')) {
|
||||||
|
dbPatch.main_exercise = normalizedPatch.mainExercise;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'secondaryExercises')) {
|
||||||
|
dbPatch.secondary_exercises = normalizedPatch.secondaryExercises;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'tasks')) {
|
||||||
|
dbPatch.tasks = normalizedPatch.tasks;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'notes')) {
|
||||||
|
dbPatch.notes = normalizedPatch.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('training_plan_sessions')
|
||||||
|
.update(dbPatch)
|
||||||
|
.eq('id', sessionId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
setActivePlan(prevActive);
|
||||||
|
setHistoryPlans(prevHistory);
|
||||||
|
setError(updateError.message || 'Session-Änderung konnte nicht gespeichert werden.');
|
||||||
|
return { ok: false, error: updateError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}, [activePlan, canUseSupabase, historyPlans]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activePlan,
|
||||||
|
historyPlans,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
loadPlans,
|
||||||
|
saveGeneratedPlan,
|
||||||
|
updateSessionState,
|
||||||
|
updateSessionPartial
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTrainingPlans;
|
||||||
16
src/index.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Space Grotesk', 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
127
src/lib/assessmentShareService.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { calculateExercise } from '../utils/patCalculations'
|
||||||
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
|
const hydrateExercise = (exercise = {}) => {
|
||||||
|
const normalized = {
|
||||||
|
name: exercise.name || '',
|
||||||
|
soll: exercise.soll ?? 0,
|
||||||
|
faktor: exercise.faktor ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exercise.subA && exercise.subB) {
|
||||||
|
normalized.subA = exercise.subA
|
||||||
|
normalized.subB = exercise.subB
|
||||||
|
if (exercise.subLabels) {
|
||||||
|
normalized.subLabels = exercise.subLabels
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
normalized.values = exercise.values || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const { durchschnitt, points } = calculateExercise({
|
||||||
|
...normalized,
|
||||||
|
values: normalized.values || []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
durchschnitt,
|
||||||
|
points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapSharedAssessment = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
patType: row.pat_type,
|
||||||
|
datum: row.datum,
|
||||||
|
name: row.name,
|
||||||
|
exercises: (row.exercises || []).map(hydrateExercise)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSingleRow = (data) => (Array.isArray(data) ? data[0] || null : data || null)
|
||||||
|
|
||||||
|
const buildShareUrl = (shareToken) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return `?share=${shareToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(window.location.origin + window.location.pathname)
|
||||||
|
url.searchParams.set('share', shareToken)
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAssessmentShareLink = async (assessment) => {
|
||||||
|
if (!assessment) {
|
||||||
|
throw new Error('Kein Test zum Teilen gefunden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
throw new Error('Supabase ist nicht konfiguriert.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc('create_or_get_assessment_share', {
|
||||||
|
p_assessment_id: assessment.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
const row = getSingleRow(data)
|
||||||
|
if (!row?.share_token) {
|
||||||
|
throw new Error('Kein Freigabetoken erhalten.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: row.share_token,
|
||||||
|
url: buildShareUrl(row.share_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listAssessmentShares = async (assessmentIds = []) => {
|
||||||
|
if (!supabase) {
|
||||||
|
throw new Error('Supabase ist nicht konfiguriert.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assessmentIds.length) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assessment_shares')
|
||||||
|
.select('assessment_id, share_token')
|
||||||
|
.in('assessment_id', assessmentIds)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
return (data || []).reduce((acc, row) => {
|
||||||
|
acc[row.assessment_id] = row.share_token
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revokeAssessmentShare = async (assessmentId) => {
|
||||||
|
if (!supabase) {
|
||||||
|
throw new Error('Supabase ist nicht konfiguriert.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('assessment_shares')
|
||||||
|
.delete()
|
||||||
|
.eq('assessment_id', assessmentId)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSharedAssessment = async (shareToken) => {
|
||||||
|
if (!supabase) {
|
||||||
|
throw new Error('Supabase ist nicht konfiguriert.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc('get_shared_assessment', {
|
||||||
|
p_share_token: shareToken
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
const row = getSingleRow(data)
|
||||||
|
return row ? mapSharedAssessment(row) : null
|
||||||
|
}
|
||||||
12
src/lib/supabaseClient.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
console.warn('Supabase env vars missing. Auth is disabled until VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are set.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = supabaseUrl && supabaseAnonKey
|
||||||
|
? createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
: null
|
||||||
66
src/lib/trainingPlanService.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
|
||||||
|
const normalizeTasks = (tasks) => {
|
||||||
|
if (!Array.isArray(tasks)) return [];
|
||||||
|
return tasks.map((task) => ({
|
||||||
|
type: task?.type || 'task',
|
||||||
|
title: task?.title || '',
|
||||||
|
durationMin: Number(task?.durationMin || 0),
|
||||||
|
repetitions: task?.repetitions ?? null,
|
||||||
|
intensity: task?.intensity || null,
|
||||||
|
instructions: task?.instructions || ''
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapSessionToRpc = (session) => ({
|
||||||
|
weekNo: Number(session?.weekNo || session?.week_no || 1),
|
||||||
|
sessionNo: Number(session?.sessionNo || session?.session_no || 1),
|
||||||
|
mainExercise: session?.mainExercise || session?.main_exercise || 'Basistechnik',
|
||||||
|
secondaryExercises: Array.isArray(session?.secondaryExercises)
|
||||||
|
? session.secondaryExercises
|
||||||
|
: Array.isArray(session?.secondary_exercises)
|
||||||
|
? session.secondary_exercises
|
||||||
|
: [],
|
||||||
|
tasks: normalizeTasks(session?.tasks),
|
||||||
|
state: session?.state || 'open',
|
||||||
|
notes: session?.notes || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveTrainingPlanWithSessions = async ({
|
||||||
|
patType,
|
||||||
|
analysisSnapshot,
|
||||||
|
durationWeeks,
|
||||||
|
sessionsPerWeek,
|
||||||
|
sessions
|
||||||
|
}) => {
|
||||||
|
if (!supabase) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error('Supabase ist nicht konfiguriert.')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
p_pat_type: patType,
|
||||||
|
p_analysis_snapshot: analysisSnapshot,
|
||||||
|
p_duration_weeks: Number(durationWeeks),
|
||||||
|
p_sessions_per_week: Number(sessionsPerWeek),
|
||||||
|
p_sessions: (sessions || []).map(mapSessionToRpc)
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc('create_training_plan_with_sessions', payload);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
planId: data
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
187
src/utils/analysisEngine.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { calculateTotal } from './patCalculations';
|
||||||
|
|
||||||
|
const toNumber = (value, fallback = 0) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (value, precision = 4) => {
|
||||||
|
const factor = 10 ** precision;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mean = (values) => {
|
||||||
|
if (!values.length) return 0;
|
||||||
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stddev = (values) => {
|
||||||
|
if (values.length <= 1) return 0;
|
||||||
|
const avg = mean(values);
|
||||||
|
const variance = mean(values.map((value) => (value - avg) ** 2));
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByDateDesc = (items = []) =>
|
||||||
|
[...items].sort((left, right) => {
|
||||||
|
const leftDate = new Date(left?.datum || 0).getTime();
|
||||||
|
const rightDate = new Date(right?.datum || 0).getTime();
|
||||||
|
|
||||||
|
if (leftDate === rightDate) {
|
||||||
|
const leftCreated = new Date(left?.created_at || 0).getTime();
|
||||||
|
const rightCreated = new Date(right?.created_at || 0).getTime();
|
||||||
|
return rightCreated - leftCreated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rightDate - leftDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectSampleValues = (exercise) => {
|
||||||
|
if (!exercise || typeof exercise !== 'object') return [];
|
||||||
|
|
||||||
|
if (Array.isArray(exercise.subA) || Array.isArray(exercise.subB)) {
|
||||||
|
return [...(exercise.subA || []), ...(exercise.subB || [])]
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (exercise.values || [])
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExerciseActual = (exercise, missingStrategy = 'zero') => {
|
||||||
|
if (!exercise) return missingStrategy === 'zero' ? 0 : null;
|
||||||
|
|
||||||
|
const avg = Number(exercise.durchschnitt);
|
||||||
|
if (Number.isFinite(avg)) return avg;
|
||||||
|
|
||||||
|
const samples = collectSampleValues(exercise);
|
||||||
|
if (!samples.length) return missingStrategy === 'zero' ? 0 : null;
|
||||||
|
|
||||||
|
return mean(samples);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateLabel = (value) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAnalysis = (assessments = [], patType, options = {}) => {
|
||||||
|
const windowSize = toNumber(options.windowSize, 5);
|
||||||
|
const missingStrategy = options.missingStrategy || 'zero';
|
||||||
|
|
||||||
|
const matchingAssessments = sortByDateDesc(
|
||||||
|
assessments.filter((assessment) => assessment?.patType === patType)
|
||||||
|
);
|
||||||
|
|
||||||
|
const windowedAssessments = matchingAssessments.slice(0, windowSize);
|
||||||
|
|
||||||
|
if (!windowedAssessments.length) {
|
||||||
|
return {
|
||||||
|
windowedAssessments: [],
|
||||||
|
exerciseMetrics: [],
|
||||||
|
topWeaknesses: [],
|
||||||
|
topStrengths: [],
|
||||||
|
trendSeries: [],
|
||||||
|
radarSeries: [],
|
||||||
|
isLimitedData: false,
|
||||||
|
patType,
|
||||||
|
windowSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAssessment = windowedAssessments[0];
|
||||||
|
const baseExercises = latestAssessment?.exercises || [];
|
||||||
|
const maxFactor = Math.max(...baseExercises.map((exercise) => toNumber(exercise?.faktor, 0)), 1);
|
||||||
|
|
||||||
|
const exerciseMetrics = baseExercises.map((baseExercise, index) => {
|
||||||
|
const actualSeries = windowedAssessments.map((assessment) => {
|
||||||
|
const exercise = assessment?.exercises?.[index];
|
||||||
|
const actual = getExerciseActual(exercise, missingStrategy);
|
||||||
|
return actual === null ? 0 : actual;
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualMean = mean(actualSeries);
|
||||||
|
const soll = toNumber(baseExercise?.soll, 0);
|
||||||
|
const factor = toNumber(baseExercise?.faktor, 0);
|
||||||
|
|
||||||
|
const normalizedSoll = soll > 0 ? soll : 1;
|
||||||
|
const gapScore = Math.max(0, (soll - actualMean) / normalizedSoll);
|
||||||
|
const factorScore = maxFactor > 0 ? factor / maxFactor : 0;
|
||||||
|
const consistencyScore = Math.min(1, stddev(actualSeries) / normalizedSoll);
|
||||||
|
const priorityScore = gapScore * factorScore * (1 + consistencyScore);
|
||||||
|
|
||||||
|
const overNorm = Math.max(0, (actualMean - soll) / normalizedSoll);
|
||||||
|
const stability = 1 - consistencyScore;
|
||||||
|
const strengthScore = overNorm * factorScore * Math.max(stability, 0.1);
|
||||||
|
|
||||||
|
const latestActual = actualSeries[0] || 0;
|
||||||
|
const oldestActual = actualSeries[actualSeries.length - 1] || 0;
|
||||||
|
const trendDelta = latestActual - oldestActual;
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
name: baseExercise?.name || `Übung ${index + 1}`,
|
||||||
|
soll,
|
||||||
|
factor,
|
||||||
|
actualMean: round(actualMean, 4),
|
||||||
|
latestActual: round(latestActual, 4),
|
||||||
|
trendDelta: round(trendDelta, 4),
|
||||||
|
gapScore: round(gapScore, 4),
|
||||||
|
consistencyScore: round(consistencyScore, 4),
|
||||||
|
priorityScore: round(priorityScore, 4),
|
||||||
|
strengthScore: round(strengthScore, 4),
|
||||||
|
actualSeries: actualSeries.map((value) => round(value, 4))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const topWeaknesses = [...exerciseMetrics]
|
||||||
|
.sort((left, right) => right.priorityScore - left.priorityScore)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
const topStrengths = [...exerciseMetrics]
|
||||||
|
.sort((left, right) => right.strengthScore - left.strengthScore)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
const trendSeries = [...windowedAssessments]
|
||||||
|
.reverse()
|
||||||
|
.map((assessment) => ({
|
||||||
|
assessmentId: assessment.id,
|
||||||
|
datum: assessment.datum,
|
||||||
|
label: formatDateLabel(assessment.datum),
|
||||||
|
totalPoints: Math.round(calculateTotal(assessment.exercises || []))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const radarSeries = [...exerciseMetrics]
|
||||||
|
.sort((left, right) => right.factor - left.factor)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((metric) => ({
|
||||||
|
name: metric.name,
|
||||||
|
soll: metric.soll,
|
||||||
|
actual: metric.actualMean,
|
||||||
|
ratio: metric.soll > 0 ? round(Math.min(1.2, metric.actualMean / metric.soll), 4) : 0,
|
||||||
|
priorityScore: metric.priorityScore
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
windowedAssessments,
|
||||||
|
exerciseMetrics,
|
||||||
|
topWeaknesses,
|
||||||
|
topStrengths,
|
||||||
|
trendSeries,
|
||||||
|
radarSeries,
|
||||||
|
isLimitedData: windowedAssessments.length < windowSize,
|
||||||
|
patType,
|
||||||
|
windowSize
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildAnalysis;
|
||||||
109
src/utils/analysisEngine.test.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildAnalysis } from './analysisEngine';
|
||||||
|
|
||||||
|
const createExercise = (name, soll, faktor, durchschnitt) => ({
|
||||||
|
name,
|
||||||
|
soll,
|
||||||
|
faktor,
|
||||||
|
durchschnitt,
|
||||||
|
points: durchschnitt * faktor
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAssessment = (id, datum, exercises) => ({
|
||||||
|
id,
|
||||||
|
patType: 'PAT 1',
|
||||||
|
datum,
|
||||||
|
name: `Spieler ${id}`,
|
||||||
|
exercises
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAnalysis', () => {
|
||||||
|
it('uses the latest 5 assessments for windowing', () => {
|
||||||
|
const baseExercises = [
|
||||||
|
createExercise('1) Faktor hoch konstant', 10, 40, 5),
|
||||||
|
createExercise('2) Faktor mittel konstant', 10, 20, 5),
|
||||||
|
createExercise('3) Faktor hoch schwankend', 10, 40, 5),
|
||||||
|
createExercise('4) Stärke', 5, 10, 7)
|
||||||
|
];
|
||||||
|
|
||||||
|
const assessments = [
|
||||||
|
createAssessment('a1', '2026-02-01', baseExercises),
|
||||||
|
createAssessment('a2', '2026-02-02', baseExercises),
|
||||||
|
createAssessment('a3', '2026-02-03', baseExercises),
|
||||||
|
createAssessment('a4', '2026-02-04', baseExercises),
|
||||||
|
createAssessment('a5', '2026-02-05', baseExercises),
|
||||||
|
createAssessment('a6', '2026-02-06', baseExercises)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildAnalysis(assessments, 'PAT 1', { windowSize: 5, missingStrategy: 'zero' });
|
||||||
|
|
||||||
|
expect(result.windowedAssessments).toHaveLength(5);
|
||||||
|
expect(result.windowedAssessments[0].id).toBe('a6');
|
||||||
|
expect(result.windowedAssessments[4].id).toBe('a2');
|
||||||
|
expect(result.isLimitedData).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills missing exercise values with zero and marks limited data when less than window', () => {
|
||||||
|
const assessmentA = createAssessment('a1', '2026-02-03', [
|
||||||
|
createExercise('1) Technik', 10, 30, 6)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assessmentB = createAssessment('a2', '2026-02-04', []);
|
||||||
|
|
||||||
|
const assessmentC = createAssessment('a3', '2026-02-05', [
|
||||||
|
createExercise('1) Technik', 10, 30, 4)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = buildAnalysis([assessmentA, assessmentB, assessmentC], 'PAT 1', {
|
||||||
|
windowSize: 5,
|
||||||
|
missingStrategy: 'zero'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isLimitedData).toBe(true);
|
||||||
|
expect(result.exerciseMetrics[0].actualSeries).toContain(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes weaknesses by gap x factor x consistency and detects strengths', () => {
|
||||||
|
const rows = [
|
||||||
|
createAssessment('a1', '2026-02-01', [
|
||||||
|
createExercise('1) Faktor hoch konstant', 10, 40, 5),
|
||||||
|
createExercise('2) Faktor mittel konstant', 10, 20, 5),
|
||||||
|
createExercise('3) Faktor hoch schwankend', 10, 40, 0),
|
||||||
|
createExercise('4) Stärke', 5, 10, 7)
|
||||||
|
]),
|
||||||
|
createAssessment('a2', '2026-02-02', [
|
||||||
|
createExercise('1) Faktor hoch konstant', 10, 40, 5),
|
||||||
|
createExercise('2) Faktor mittel konstant', 10, 20, 5),
|
||||||
|
createExercise('3) Faktor hoch schwankend', 10, 40, 10),
|
||||||
|
createExercise('4) Stärke', 5, 10, 7)
|
||||||
|
]),
|
||||||
|
createAssessment('a3', '2026-02-03', [
|
||||||
|
createExercise('1) Faktor hoch konstant', 10, 40, 5),
|
||||||
|
createExercise('2) Faktor mittel konstant', 10, 20, 5),
|
||||||
|
createExercise('3) Faktor hoch schwankend', 10, 40, 0),
|
||||||
|
createExercise('4) Stärke', 5, 10, 7)
|
||||||
|
]),
|
||||||
|
createAssessment('a4', '2026-02-04', [
|
||||||
|
createExercise('1) Faktor hoch konstant', 10, 40, 5),
|
||||||
|
createExercise('2) Faktor mittel konstant', 10, 20, 5),
|
||||||
|
createExercise('3) Faktor hoch schwankend', 10, 40, 10),
|
||||||
|
createExercise('4) Stärke', 5, 10, 7)
|
||||||
|
]),
|
||||||
|
createAssessment('a5', '2026-02-05', [
|
||||||
|
createExercise('1) Faktor hoch konstant', 10, 40, 5),
|
||||||
|
createExercise('2) Faktor mittel konstant', 10, 20, 5),
|
||||||
|
createExercise('3) Faktor hoch schwankend', 10, 40, 5),
|
||||||
|
createExercise('4) Stärke', 5, 10, 7)
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildAnalysis(rows, 'PAT 1', { windowSize: 5, missingStrategy: 'zero' });
|
||||||
|
|
||||||
|
expect(result.topWeaknesses[0].name).toBe('3) Faktor hoch schwankend');
|
||||||
|
expect(result.topWeaknesses[1].name).toBe('1) Faktor hoch konstant');
|
||||||
|
expect(result.topWeaknesses[2].name).toBe('2) Faktor mittel konstant');
|
||||||
|
|
||||||
|
expect(result.topStrengths[0].name).toBe('4) Stärke');
|
||||||
|
expect(result.topStrengths[0].strengthScore).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
src/utils/assessmentState.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export const cloneAssessment = (assessment) =>
|
||||||
|
assessment ? JSON.parse(JSON.stringify(assessment)) : null;
|
||||||
|
|
||||||
|
const hasValue = (value) => {
|
||||||
|
if (value === null || typeof value === 'undefined') return false;
|
||||||
|
if (typeof value === 'string') return value.trim() !== '';
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isExerciseComplete = (exercise = {}) => {
|
||||||
|
if (exercise.subA && exercise.subB) {
|
||||||
|
return [...(exercise.subA || []), ...(exercise.subB || [])].every(hasValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (exercise.values || []).every(hasValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAssessmentComplete = (assessment) => {
|
||||||
|
if (!assessment) return false;
|
||||||
|
if (!hasValue(assessment.name) || !hasValue(assessment.datum)) return false;
|
||||||
|
|
||||||
|
const exercises = assessment.exercises || [];
|
||||||
|
if (!exercises.length) return false;
|
||||||
|
|
||||||
|
return exercises.every(isExerciseComplete);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFinalizeDisabledReason = (assessment) => {
|
||||||
|
if (!assessment) return 'Kein Test geöffnet';
|
||||||
|
if (assessment.isFinalized) return 'Dieser Test wurde bereits final abgeschlossen';
|
||||||
|
if (!hasValue(assessment.name) || !hasValue(assessment.datum)) {
|
||||||
|
return 'Bitte zuerst Name und Datum ausfüllen';
|
||||||
|
}
|
||||||
|
if (!isAssessmentComplete(assessment)) {
|
||||||
|
return 'Bitte alle Übungsfelder ausfüllen, bevor du final abschließt';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
44
src/utils/assessmentState.test.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
cloneAssessment,
|
||||||
|
getFinalizeDisabledReason,
|
||||||
|
isAssessmentComplete,
|
||||||
|
isExerciseComplete
|
||||||
|
} from './assessmentState';
|
||||||
|
|
||||||
|
describe('assessmentState', () => {
|
||||||
|
it('treats regular exercises as complete only when all values are filled', () => {
|
||||||
|
expect(isExerciseComplete({ values: ['1', '2', '3'] })).toBe(true);
|
||||||
|
expect(isExerciseComplete({ values: ['1', '', '3'] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats split exercises as complete only when both rows are filled', () => {
|
||||||
|
expect(isExerciseComplete({ subA: ['1', '2'], subB: ['3', '4'] })).toBe(true);
|
||||||
|
expect(isExerciseComplete({ subA: ['1', '2'], subB: ['3', ''] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks assessments incomplete until name, date and all exercise values are present', () => {
|
||||||
|
const baseAssessment = {
|
||||||
|
name: 'Stefan',
|
||||||
|
datum: '2026-03-21',
|
||||||
|
exercises: [{ values: ['1', '2', '3'] }]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isAssessmentComplete(baseAssessment)).toBe(true);
|
||||||
|
expect(getFinalizeDisabledReason(baseAssessment)).toBe('');
|
||||||
|
expect(isAssessmentComplete({ ...baseAssessment, name: '' })).toBe(false);
|
||||||
|
expect(isAssessmentComplete({ ...baseAssessment, exercises: [{ values: ['1', '', '3'] }] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep clones assessments before editing', () => {
|
||||||
|
const source = {
|
||||||
|
name: 'Stefan',
|
||||||
|
exercises: [{ values: ['1', '2'] }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const clone = cloneAssessment(source);
|
||||||
|
clone.exercises[0].values[0] = '9';
|
||||||
|
|
||||||
|
expect(source.exercises[0].values[0]).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/utils/exerciseInputRules.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export const sanitizeNumericInput = (value) => String(value || '').replace(/\D/g, '');
|
||||||
|
|
||||||
|
const getExerciseRange = (exercise, subType = null) => {
|
||||||
|
if (subType && exercise?.subValueRanges?.[subType]) {
|
||||||
|
return exercise.subValueRanges[subType];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minValue: exercise?.minValue,
|
||||||
|
maxValue: exercise?.maxValue
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeExerciseValue = (exercise, value, subType = null) => {
|
||||||
|
const sanitizedValue = sanitizeNumericInput(value);
|
||||||
|
|
||||||
|
if (!sanitizedValue) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(sanitizedValue, 10);
|
||||||
|
const range = getExerciseRange(exercise, subType);
|
||||||
|
const minValue = Number.isFinite(range?.minValue) ? range.minValue : 0;
|
||||||
|
const maxValue = Number.isFinite(range?.maxValue) ? range.maxValue : null;
|
||||||
|
|
||||||
|
const clampedValue = maxValue === null
|
||||||
|
? Math.max(parsedValue, minValue)
|
||||||
|
: Math.min(Math.max(parsedValue, minValue), maxValue);
|
||||||
|
|
||||||
|
return String(clampedValue);
|
||||||
|
};
|
||||||
29
src/utils/exerciseInputRules.test.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeExerciseValue } from './exerciseInputRules';
|
||||||
|
|
||||||
|
describe('normalizeExerciseValue', () => {
|
||||||
|
it('keeps only digits', () => {
|
||||||
|
expect(normalizeExerciseValue({}, '1a-2')).toBe('12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps values to the configured range', () => {
|
||||||
|
expect(normalizeExerciseValue({ minValue: 0, maxValue: 4 }, '8')).toBe('4');
|
||||||
|
expect(normalizeExerciseValue({ minValue: 0, maxValue: 3 }, '9')).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports separate ranges for split exercise rows', () => {
|
||||||
|
const exercise = {
|
||||||
|
subValueRanges: {
|
||||||
|
a: { minValue: 0, maxValue: 6 },
|
||||||
|
b: { minValue: 0, maxValue: 10 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(normalizeExerciseValue(exercise, '8', 'a')).toBe('6');
|
||||||
|
expect(normalizeExerciseValue(exercise, '12', 'b')).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps empty values empty', () => {
|
||||||
|
expect(normalizeExerciseValue({ minValue: 0, maxValue: 4 }, '')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/utils/patCalculations.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export const calculateExercise = (exercise) => {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (exercise.subA && exercise.subB) {
|
||||||
|
[...exercise.subA, ...exercise.subB].forEach((val) => {
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
sum += num;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
exercise.values.forEach((val) => {
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
sum += num;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const durchschnitt = count > 0 ? sum / count : 0;
|
||||||
|
const points = durchschnitt * exercise.faktor;
|
||||||
|
|
||||||
|
return { durchschnitt, points };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateTotal = (exercises = []) =>
|
||||||
|
exercises.reduce((sum, ex) => sum + (ex.points || 0), 0);
|
||||||
|
|
||||||
|
export const getPatTypeColor = (patType) => {
|
||||||
|
const colors = {
|
||||||
|
'PAT Start': 'bg-green-100 text-green-800',
|
||||||
|
'PAT 1': 'bg-blue-100 text-blue-800',
|
||||||
|
'PAT 2': 'bg-purple-100 text-purple-800',
|
||||||
|
'PAT 3': 'bg-orange-100 text-orange-800'
|
||||||
|
};
|
||||||
|
return colors[patType] || 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAchievement = (patType, points) => {
|
||||||
|
const achievements = {
|
||||||
|
'PAT Start': [
|
||||||
|
{ min: 1000, color: 'bg-yellow-400', text: 'text-black', name: '2. Schwarz-Weiss Sport WPA', subtitle: 'Freizeit-Spieler' },
|
||||||
|
{ min: 800, color: 'bg-white', text: 'text-black', name: '1. Weiss', subtitle: '' },
|
||||||
|
{ min: 600, color: 'bg-gray-200', text: 'text-gray-800', name: 'PAT Start Teilnahme', subtitle: '' },
|
||||||
|
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
|
||||||
|
],
|
||||||
|
'PAT 1': [
|
||||||
|
{ min: 1000, color: 'bg-green-700', text: 'text-white', name: '4. Dunkelgrün', subtitle: 'ca. Bezirksliga' },
|
||||||
|
{ min: 800, color: 'bg-green-300', text: 'text-black', name: '3. Hellgrün', subtitle: 'ca. Kreisliga' },
|
||||||
|
{ min: 600, color: 'bg-gray-200', text: 'text-gray-800', name: 'PAT 1 Teilnahme', subtitle: '' },
|
||||||
|
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
|
||||||
|
],
|
||||||
|
'PAT 2': [
|
||||||
|
{ min: 1000, color: 'bg-blue-700', text: 'text-white', name: '6. Dunkelblau', subtitle: 'ca. Verbandsliga' },
|
||||||
|
{ min: 800, color: 'bg-blue-300', text: 'text-black', name: '5. Hellblau', subtitle: 'ca. Landesliga' },
|
||||||
|
{ min: 600, color: 'bg-gray-200', text: 'text-gray-800', name: 'PAT 2 Teilnahme', subtitle: '' },
|
||||||
|
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
|
||||||
|
],
|
||||||
|
'PAT 3': [
|
||||||
|
{ min: 1000, color: 'bg-pink-400', text: 'text-white', name: '10. Pink', subtitle: 'Ein Weltstar!' },
|
||||||
|
{ min: 850, color: 'bg-yellow-400', text: 'text-black', name: '9. Gold', subtitle: 'internationaler Topsportler' },
|
||||||
|
{ min: 700, color: 'bg-red-600', text: 'text-white', name: '8. Rot', subtitle: 'ca. 1. Bundesliga' },
|
||||||
|
{ min: 550, color: 'bg-black', text: 'text-white', name: '7. Schwarz', subtitle: 'ca. 2. Bundesliga' },
|
||||||
|
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const patAchievements = achievements[patType] || achievements['PAT Start'];
|
||||||
|
return patAchievements.find((a) => points >= a.min) || patAchievements[patAchievements.length - 1];
|
||||||
|
};
|
||||||
308
src/utils/pdfExport.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
const PAGE_MARGIN = 14
|
||||||
|
const PAGE_TOP = 12
|
||||||
|
const PAGE_BOTTOM = 22
|
||||||
|
const CONTENT_WIDTH = 210 - PAGE_MARGIN * 2
|
||||||
|
const CONTINUATION_PAGE_TOP = PAGE_MARGIN + 10
|
||||||
|
const TABLE_FONT_SIZE = 10.5
|
||||||
|
const TABLE_CELL_PADDING = 3
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const parsed = new Date(value)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeFileName = (value) =>
|
||||||
|
(value || 'pat-test')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-_]+/gi, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
const formatValue = (value) => {
|
||||||
|
if (value === '' || value === null || typeof value === 'undefined') return '—'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateText = (value, maxLength = 26) => {
|
||||||
|
const text = String(value || '—')
|
||||||
|
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_TABLE_LABEL = 'Versuch'
|
||||||
|
|
||||||
|
const estimateExerciseSectionHeight = (exercise) => {
|
||||||
|
const hasSplitRows = Boolean(exercise.subA && exercise.subB)
|
||||||
|
const valueTableHeight = hasSplitRows ? 30 : 21
|
||||||
|
const metricsTableHeight = 21
|
||||||
|
|
||||||
|
return 18 + valueTableHeight + metricsTableHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildValueTable = (exercise) => {
|
||||||
|
if (exercise.subA && exercise.subB) {
|
||||||
|
const valuesCount = Math.max(exercise.subA.length, exercise.subB.length)
|
||||||
|
const headers = [VALUE_TABLE_LABEL, ...Array.from({ length: valuesCount }, (_, index) => `${index + 1}`)]
|
||||||
|
const firstColumnWidth = 32
|
||||||
|
const valueColumnWidth = (CONTENT_WIDTH - firstColumnWidth) / valuesCount
|
||||||
|
|
||||||
|
return {
|
||||||
|
head: [headers],
|
||||||
|
body: [
|
||||||
|
['a.)', ...Array.from({ length: valuesCount }, (_, index) => formatValue(exercise.subA[index]))],
|
||||||
|
['b.)', ...Array.from({ length: valuesCount }, (_, index) => formatValue(exercise.subB[index]))]
|
||||||
|
],
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: firstColumnWidth, halign: 'center' },
|
||||||
|
...Object.fromEntries(
|
||||||
|
Array.from({ length: valuesCount }, (_, index) => [
|
||||||
|
index + 1,
|
||||||
|
{ cellWidth: valueColumnWidth, halign: 'center' }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valuesCount = exercise.values?.length || 0
|
||||||
|
const headers = [VALUE_TABLE_LABEL, ...Array.from({ length: valuesCount }, (_, index) => `${index + 1}`)]
|
||||||
|
const firstColumnWidth = 32
|
||||||
|
const valueColumnWidth = valuesCount > 0 ? (CONTENT_WIDTH - firstColumnWidth) / valuesCount : CONTENT_WIDTH - firstColumnWidth
|
||||||
|
|
||||||
|
return {
|
||||||
|
head: [headers],
|
||||||
|
body: [['Ist', ...(exercise.values || []).map(formatValue)]],
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: firstColumnWidth, halign: 'center' },
|
||||||
|
...Object.fromEntries(
|
||||||
|
Array.from({ length: valuesCount }, (_, index) => [
|
||||||
|
index + 1,
|
||||||
|
{ cellWidth: valueColumnWidth, halign: 'center' }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMetricsTable = (exercise) => ({
|
||||||
|
head: [['Soll', 'Durchschnitt ist', 'Faktor', 'Punkte']],
|
||||||
|
body: [[
|
||||||
|
formatValue(exercise.soll),
|
||||||
|
(exercise.durchschnitt || 0).toFixed(2),
|
||||||
|
formatValue(exercise.faktor),
|
||||||
|
(exercise.points || 0).toFixed(0)
|
||||||
|
]],
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' },
|
||||||
|
1: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' },
|
||||||
|
2: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' },
|
||||||
|
3: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildSummaryTable = ({ assessment, totalPoints, achievement }) => ({
|
||||||
|
head: [['Name', 'PAT Typ', 'Datum', 'Gesamtpunkte', 'Bewertung']],
|
||||||
|
body: [[
|
||||||
|
assessment.name || '—',
|
||||||
|
assessment.patType || '—',
|
||||||
|
formatDate(assessment.datum),
|
||||||
|
String(Math.round(totalPoints || 0)),
|
||||||
|
achievement?.name || '—'
|
||||||
|
]],
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 42 },
|
||||||
|
1: { cellWidth: 28 },
|
||||||
|
2: { cellWidth: 34 },
|
||||||
|
3: { cellWidth: 34, halign: 'center' },
|
||||||
|
4: { cellWidth: 44 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildFooterItems = ({ assessment, achievement, pageNumber, pageCount }) => ([
|
||||||
|
`Name: ${truncateText(assessment?.name, 20)}`,
|
||||||
|
`PAT Typ: ${truncateText(assessment?.patType, 14)}`,
|
||||||
|
`Datum: ${formatDate(assessment?.datum)}`,
|
||||||
|
`Bewertung: ${truncateText(achievement?.name, 18)}`,
|
||||||
|
`Seite ${pageNumber}/${pageCount}`
|
||||||
|
])
|
||||||
|
|
||||||
|
const ensureSectionSpace = (doc, startY, minHeight) => {
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight()
|
||||||
|
if (startY + minHeight <= pageHeight - PAGE_BOTTOM) {
|
||||||
|
return startY
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.addPage()
|
||||||
|
return CONTINUATION_PAGE_TOP
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawFooter = (doc, { assessment, achievement }) => {
|
||||||
|
const pageCount = doc.getNumberOfPages()
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight()
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth()
|
||||||
|
const footerY = pageHeight - 8
|
||||||
|
const footerXs = [
|
||||||
|
PAGE_MARGIN,
|
||||||
|
PAGE_MARGIN + 47,
|
||||||
|
PAGE_MARGIN + 88,
|
||||||
|
PAGE_MARGIN + 130,
|
||||||
|
pageWidth - PAGE_MARGIN
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||||
|
const [nameText, patTypeText, dateText, achievementText, pageText] = buildFooterItems({
|
||||||
|
assessment,
|
||||||
|
achievement,
|
||||||
|
pageNumber,
|
||||||
|
pageCount
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.setPage(pageNumber)
|
||||||
|
doc.setDrawColor(226, 232, 240)
|
||||||
|
doc.setLineWidth(0.3)
|
||||||
|
doc.line(PAGE_MARGIN, footerY - 4, pageWidth - PAGE_MARGIN, footerY - 4)
|
||||||
|
doc.setFont('helvetica', 'normal')
|
||||||
|
doc.setFontSize(8.5)
|
||||||
|
doc.setTextColor(100, 116, 139)
|
||||||
|
doc.text(nameText, footerXs[0], footerY)
|
||||||
|
doc.text(patTypeText, footerXs[1], footerY)
|
||||||
|
doc.text(dateText, footerXs[2], footerY)
|
||||||
|
doc.text(achievementText, footerXs[3], footerY)
|
||||||
|
doc.text(pageText, footerXs[4], footerY, { align: 'right' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadAssessmentPdf = async ({ assessment, totalPoints, achievement }) => {
|
||||||
|
if (!assessment) return
|
||||||
|
|
||||||
|
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
|
import('jspdf'),
|
||||||
|
import('jspdf-autotable')
|
||||||
|
])
|
||||||
|
|
||||||
|
const doc = new jsPDF({
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4'
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.setFillColor(16, 185, 129)
|
||||||
|
doc.roundedRect(PAGE_MARGIN, PAGE_TOP, CONTENT_WIDTH, 18, 4, 4, 'F')
|
||||||
|
doc.setFont('helvetica', 'bold')
|
||||||
|
doc.setFontSize(20)
|
||||||
|
doc.setTextColor(255, 255, 255)
|
||||||
|
doc.text('PAT Test Details', 20, 24)
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: 38,
|
||||||
|
margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, bottom: PAGE_BOTTOM },
|
||||||
|
theme: 'grid',
|
||||||
|
styles: {
|
||||||
|
font: 'helvetica',
|
||||||
|
fontSize: TABLE_FONT_SIZE,
|
||||||
|
cellPadding: 4,
|
||||||
|
textColor: [31, 41, 55],
|
||||||
|
lineColor: [226, 232, 240],
|
||||||
|
lineWidth: 0.3
|
||||||
|
},
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [241, 245, 249],
|
||||||
|
textColor: [71, 85, 105],
|
||||||
|
fontStyle: 'bold'
|
||||||
|
},
|
||||||
|
bodyStyles: {
|
||||||
|
fillColor: [255, 255, 255]
|
||||||
|
},
|
||||||
|
tableWidth: CONTENT_WIDTH,
|
||||||
|
...buildSummaryTable({ assessment, totalPoints, achievement })
|
||||||
|
})
|
||||||
|
|
||||||
|
;(assessment.exercises || []).forEach((exercise, index) => {
|
||||||
|
const headingY = ensureSectionSpace(
|
||||||
|
doc,
|
||||||
|
(doc.lastAutoTable?.finalY || 38) + 8,
|
||||||
|
estimateExerciseSectionHeight(exercise)
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold')
|
||||||
|
doc.setFontSize(14)
|
||||||
|
doc.setTextColor(17, 24, 39)
|
||||||
|
doc.text(exercise.name || `Übung ${index + 1}`, PAGE_MARGIN, headingY)
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: headingY + 4,
|
||||||
|
margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, bottom: PAGE_BOTTOM },
|
||||||
|
theme: 'grid',
|
||||||
|
styles: {
|
||||||
|
font: 'helvetica',
|
||||||
|
fontSize: TABLE_FONT_SIZE,
|
||||||
|
cellPadding: TABLE_CELL_PADDING,
|
||||||
|
halign: 'center',
|
||||||
|
textColor: [31, 41, 55],
|
||||||
|
lineColor: [226, 232, 240],
|
||||||
|
lineWidth: 0.3
|
||||||
|
},
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [239, 246, 255],
|
||||||
|
textColor: [30, 64, 175],
|
||||||
|
fontStyle: 'bold',
|
||||||
|
halign: 'center'
|
||||||
|
},
|
||||||
|
bodyStyles: {
|
||||||
|
fillColor: [255, 255, 255],
|
||||||
|
halign: 'center'
|
||||||
|
},
|
||||||
|
tableWidth: CONTENT_WIDTH,
|
||||||
|
pageBreak: 'avoid',
|
||||||
|
rowPageBreak: 'avoid',
|
||||||
|
...buildValueTable(exercise)
|
||||||
|
})
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: (doc.lastAutoTable?.finalY || headingY + 4) + 3,
|
||||||
|
margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, bottom: PAGE_BOTTOM },
|
||||||
|
theme: 'grid',
|
||||||
|
styles: {
|
||||||
|
font: 'helvetica',
|
||||||
|
fontSize: TABLE_FONT_SIZE,
|
||||||
|
cellPadding: TABLE_CELL_PADDING,
|
||||||
|
textColor: [31, 41, 55],
|
||||||
|
lineColor: [226, 232, 240],
|
||||||
|
lineWidth: 0.3
|
||||||
|
},
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [236, 253, 245],
|
||||||
|
textColor: [6, 95, 70],
|
||||||
|
fontStyle: 'bold'
|
||||||
|
},
|
||||||
|
bodyStyles: {
|
||||||
|
fillColor: [255, 255, 255],
|
||||||
|
halign: 'center'
|
||||||
|
},
|
||||||
|
didParseCell: ({ section, column, cell }) => {
|
||||||
|
if (section === 'body' && column.index === 3) {
|
||||||
|
cell.styles.fillColor = [241, 245, 249]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tableWidth: CONTENT_WIDTH,
|
||||||
|
pageBreak: 'avoid',
|
||||||
|
rowPageBreak: 'avoid',
|
||||||
|
...buildMetricsTable(exercise)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileName = [
|
||||||
|
sanitizeFileName(assessment.name),
|
||||||
|
sanitizeFileName(assessment.patType),
|
||||||
|
sanitizeFileName(assessment.datum)
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('_')
|
||||||
|
|
||||||
|
drawFooter(doc, { assessment, achievement })
|
||||||
|
doc.save(`${fileName || 'pat-test'}.pdf`)
|
||||||
|
}
|
||||||
127
src/utils/trainingPlanGenerator.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
const DEFAULT_MAIN_PATTERN = [0, 1, 0, 2, 0, 1, 0, 1, 2, 0];
|
||||||
|
|
||||||
|
const clampToAllowed = (value, allowed, fallback) =>
|
||||||
|
allowed.includes(value) ? value : fallback;
|
||||||
|
|
||||||
|
const getIntensity = (gapScore) => {
|
||||||
|
if (gapScore >= 0.7) return 'hoch';
|
||||||
|
if (gapScore >= 0.4) return 'mittel';
|
||||||
|
return 'leicht';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWarmupTask = () => ({
|
||||||
|
type: 'warmup',
|
||||||
|
title: 'Warm-up',
|
||||||
|
durationMin: 10,
|
||||||
|
instructions: 'Lockere Stoßserie, Fokus auf saubere Ansprechposition und Rhythmus.'
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildMainTask = (exerciseName, intensity) => {
|
||||||
|
const repetitionsByIntensity = {
|
||||||
|
hoch: 24,
|
||||||
|
mittel: 18,
|
||||||
|
leicht: 12
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'main',
|
||||||
|
title: `Hauptblock: ${exerciseName}`,
|
||||||
|
durationMin: intensity === 'hoch' ? 35 : 28,
|
||||||
|
repetitions: repetitionsByIntensity[intensity],
|
||||||
|
intensity,
|
||||||
|
instructions: 'Dokumentiere Trefferquote pro Serie und halte Pausen kurz (30-45 Sek.).'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSecondaryTask = (exerciseName, order) => ({
|
||||||
|
type: 'secondary',
|
||||||
|
title: `Nebenblock ${order}: ${exerciseName}`,
|
||||||
|
durationMin: 18,
|
||||||
|
repetitions: 10,
|
||||||
|
intensity: 'mittel',
|
||||||
|
instructions: 'Konstanter Ablauf, Fokus auf Präzision statt Tempo.'
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCooldownTask = () => ({
|
||||||
|
type: 'cooldown',
|
||||||
|
title: 'Cool-down',
|
||||||
|
durationMin: 8,
|
||||||
|
instructions: 'Kurze Reflexion: 2 Dinge die gut liefen, 1 Punkt für den nächsten Tag.'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickMainExercise = (index, topWeaknesses) => {
|
||||||
|
if (!topWeaknesses.length) {
|
||||||
|
return { name: 'Generelle Basistechnik', gapScore: 0.3, priorityScore: 0.3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const patternIndex = DEFAULT_MAIN_PATTERN[index % DEFAULT_MAIN_PATTERN.length];
|
||||||
|
const safeIndex = patternIndex % topWeaknesses.length;
|
||||||
|
return topWeaknesses[safeIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickSecondaryExercises = (mainExerciseName, topWeaknesses, topStrengths) => {
|
||||||
|
const pool = [
|
||||||
|
...topWeaknesses.map((item) => item?.name),
|
||||||
|
...topStrengths.map((item) => item?.name)
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const uniquePool = [...new Set(pool)].filter((name) => name !== mainExerciseName);
|
||||||
|
|
||||||
|
if (uniquePool.length >= 2) return uniquePool.slice(0, 2);
|
||||||
|
if (uniquePool.length === 1) return [uniquePool[0], 'Positionsspiel Basis'];
|
||||||
|
return ['Positionsspiel Basis', 'Stoßroutine Stabilisierung'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateTrainingPlan = ({
|
||||||
|
topWeaknesses = [],
|
||||||
|
topStrengths = [],
|
||||||
|
durationWeeks = 4,
|
||||||
|
sessionsPerWeek = 3,
|
||||||
|
sessionMix = '1-main-2-secondary'
|
||||||
|
} = {}) => {
|
||||||
|
const safeDurationWeeks = clampToAllowed(Number(durationWeeks), [2, 4, 6], 4);
|
||||||
|
const safeSessionsPerWeek = clampToAllowed(Number(sessionsPerWeek), [2, 3, 4], 3);
|
||||||
|
|
||||||
|
const totalSessions = safeDurationWeeks * safeSessionsPerWeek;
|
||||||
|
|
||||||
|
const sessions = Array.from({ length: totalSessions }, (_, index) => {
|
||||||
|
const weekNo = Math.floor(index / safeSessionsPerWeek) + 1;
|
||||||
|
const sessionNo = (index % safeSessionsPerWeek) + 1;
|
||||||
|
|
||||||
|
const mainExercise = pickMainExercise(index, topWeaknesses);
|
||||||
|
const intensity = getIntensity(Number(mainExercise?.gapScore || 0));
|
||||||
|
const secondaryExercises = pickSecondaryExercises(mainExercise?.name, topWeaknesses, topStrengths);
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
buildWarmupTask(),
|
||||||
|
buildMainTask(mainExercise?.name || 'Basistechnik', intensity),
|
||||||
|
buildSecondaryTask(secondaryExercises[0], 1),
|
||||||
|
buildSecondaryTask(secondaryExercises[1], 2),
|
||||||
|
buildCooldownTask()
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekNo,
|
||||||
|
sessionNo,
|
||||||
|
mainExercise: mainExercise?.name || 'Basistechnik',
|
||||||
|
secondaryExercises,
|
||||||
|
sessionMix,
|
||||||
|
intensity,
|
||||||
|
state: 'open',
|
||||||
|
notes: '',
|
||||||
|
tasks,
|
||||||
|
meta: {
|
||||||
|
gapScore: Number(mainExercise?.gapScore || 0),
|
||||||
|
priorityScore: Number(mainExercise?.priorityScore || 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationWeeks: safeDurationWeeks,
|
||||||
|
sessionsPerWeek: safeSessionsPerWeek,
|
||||||
|
sessions
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default generateTrainingPlan;
|
||||||
71
src/utils/trainingPlanGenerator.test.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateTrainingPlan } from './trainingPlanGenerator';
|
||||||
|
|
||||||
|
const topWeaknesses = [
|
||||||
|
{ name: 'A', gapScore: 0.8, priorityScore: 0.9 },
|
||||||
|
{ name: 'B', gapScore: 0.6, priorityScore: 0.7 },
|
||||||
|
{ name: 'C', gapScore: 0.4, priorityScore: 0.5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const topStrengths = [
|
||||||
|
{ name: 'S1' },
|
||||||
|
{ name: 'S2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('generateTrainingPlan', () => {
|
||||||
|
it('creates expected number of sessions for duration and cadence', () => {
|
||||||
|
const plan = generateTrainingPlan({
|
||||||
|
topWeaknesses,
|
||||||
|
topStrengths,
|
||||||
|
durationWeeks: 2,
|
||||||
|
sessionsPerWeek: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.sessions).toHaveLength(6);
|
||||||
|
expect(plan.durationWeeks).toBe(2);
|
||||||
|
expect(plan.sessionsPerWeek).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures each session has one main and two secondary exercises', () => {
|
||||||
|
const plan = generateTrainingPlan({
|
||||||
|
topWeaknesses,
|
||||||
|
topStrengths,
|
||||||
|
durationWeeks: 4,
|
||||||
|
sessionsPerWeek: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const session of plan.sessions) {
|
||||||
|
expect(session.mainExercise).toBeTruthy();
|
||||||
|
expect(session.secondaryExercises).toHaveLength(2);
|
||||||
|
|
||||||
|
const mainTasks = session.tasks.filter((task) => task.type === 'main');
|
||||||
|
const secondaryTasks = session.tasks.filter((task) => task.type === 'secondary');
|
||||||
|
|
||||||
|
expect(mainTasks).toHaveLength(1);
|
||||||
|
expect(secondaryTasks).toHaveLength(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates weighted main focus with 50/30/20 pattern', () => {
|
||||||
|
const plan = generateTrainingPlan({
|
||||||
|
topWeaknesses,
|
||||||
|
topStrengths,
|
||||||
|
durationWeeks: 6,
|
||||||
|
sessionsPerWeek: 4
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.sessions).toHaveLength(24);
|
||||||
|
|
||||||
|
const counts = plan.sessions.reduce(
|
||||||
|
(accumulator, session) => {
|
||||||
|
accumulator[session.mainExercise] = (accumulator[session.mainExercise] || 0) + 1;
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{ A: 0, B: 0, C: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(counts.A).toBe(12);
|
||||||
|
expect(counts.B).toBe(7);
|
||||||
|
expect(counts.C).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
supabase/migrations/20240211000000_assessments.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- Assessments table for PAT Stats
|
||||||
|
create table if not exists public.assessments (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
pat_type text not null,
|
||||||
|
datum date not null,
|
||||||
|
name text not null,
|
||||||
|
exercises jsonb not null default '[]'::jsonb,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Keep updated_at current
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
drop trigger if exists trg_assessments_updated_at on public.assessments;
|
||||||
|
create trigger trg_assessments_updated_at
|
||||||
|
before update on public.assessments
|
||||||
|
for each row execute function public.set_updated_at();
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
alter table public.assessments enable row level security;
|
||||||
|
|
||||||
|
-- Policies: authenticated users manage only their own rows
|
||||||
|
create policy if not exists "Allow users to insert own assessments"
|
||||||
|
on public.assessments
|
||||||
|
for insert with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy if not exists "Allow users to select own assessments"
|
||||||
|
on public.assessments
|
||||||
|
for select using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy if not exists "Allow users to update own assessments"
|
||||||
|
on public.assessments
|
||||||
|
for update using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy if not exists "Allow users to delete own assessments"
|
||||||
|
on public.assessments
|
||||||
|
for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Helpful index
|
||||||
|
create index if not exists idx_assessments_user_id_created_at
|
||||||
|
on public.assessments (user_id, created_at desc);
|
||||||
233
supabase/migrations/20260301000000_training_plans.sql
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
-- Training plans and sessions for analysis-driven coaching
|
||||||
|
create table if not exists public.training_plans (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
pat_type text not null,
|
||||||
|
status text not null default 'active' check (status in ('active', 'archived')),
|
||||||
|
analysis_snapshot jsonb not null default '{}'::jsonb,
|
||||||
|
duration_weeks integer not null check (duration_weeks in (2, 4, 6)),
|
||||||
|
sessions_per_week integer not null check (sessions_per_week in (2, 3, 4)),
|
||||||
|
generated_at timestamptz not null default now(),
|
||||||
|
archived_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.training_plan_sessions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
plan_id uuid not null references public.training_plans(id) on delete cascade,
|
||||||
|
week_no integer not null,
|
||||||
|
session_no integer not null,
|
||||||
|
main_exercise text not null,
|
||||||
|
secondary_exercises jsonb not null default '[]'::jsonb,
|
||||||
|
tasks jsonb not null default '[]'::jsonb,
|
||||||
|
state text not null default 'open' check (state in ('open', 'done', 'skipped')),
|
||||||
|
notes text,
|
||||||
|
completed_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (plan_id, week_no, session_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- updated_at trigger reuse
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
drop trigger if exists trg_training_plans_updated_at on public.training_plans;
|
||||||
|
create trigger trg_training_plans_updated_at
|
||||||
|
before update on public.training_plans
|
||||||
|
for each row execute function public.set_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists trg_training_plan_sessions_updated_at on public.training_plan_sessions;
|
||||||
|
create trigger trg_training_plan_sessions_updated_at
|
||||||
|
before update on public.training_plan_sessions
|
||||||
|
for each row execute function public.set_updated_at();
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
alter table public.training_plans enable row level security;
|
||||||
|
alter table public.training_plan_sessions enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to insert own training plans" on public.training_plans;
|
||||||
|
create policy "Allow users to insert own training plans"
|
||||||
|
on public.training_plans
|
||||||
|
for insert
|
||||||
|
with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to select own training plans" on public.training_plans;
|
||||||
|
create policy "Allow users to select own training plans"
|
||||||
|
on public.training_plans
|
||||||
|
for select
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to update own training plans" on public.training_plans;
|
||||||
|
create policy "Allow users to update own training plans"
|
||||||
|
on public.training_plans
|
||||||
|
for update
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to delete own training plans" on public.training_plans;
|
||||||
|
create policy "Allow users to delete own training plans"
|
||||||
|
on public.training_plans
|
||||||
|
for delete
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to insert own training plan sessions" on public.training_plan_sessions;
|
||||||
|
create policy "Allow users to insert own training plan sessions"
|
||||||
|
on public.training_plan_sessions
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.training_plans tp
|
||||||
|
where tp.id = training_plan_sessions.plan_id
|
||||||
|
and tp.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to select own training plan sessions" on public.training_plan_sessions;
|
||||||
|
create policy "Allow users to select own training plan sessions"
|
||||||
|
on public.training_plan_sessions
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.training_plans tp
|
||||||
|
where tp.id = training_plan_sessions.plan_id
|
||||||
|
and tp.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to update own training plan sessions" on public.training_plan_sessions;
|
||||||
|
create policy "Allow users to update own training plan sessions"
|
||||||
|
on public.training_plan_sessions
|
||||||
|
for update
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.training_plans tp
|
||||||
|
where tp.id = training_plan_sessions.plan_id
|
||||||
|
and tp.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to delete own training plan sessions" on public.training_plan_sessions;
|
||||||
|
create policy "Allow users to delete own training plan sessions"
|
||||||
|
on public.training_plan_sessions
|
||||||
|
for delete
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.training_plans tp
|
||||||
|
where tp.id = training_plan_sessions.plan_id
|
||||||
|
and tp.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_training_plans_user_pat_generated
|
||||||
|
on public.training_plans(user_id, pat_type, generated_at desc);
|
||||||
|
|
||||||
|
create unique index if not exists idx_training_plans_user_pat_active_unique
|
||||||
|
on public.training_plans(user_id, pat_type)
|
||||||
|
where status = 'active';
|
||||||
|
|
||||||
|
create index if not exists idx_training_plan_sessions_plan_week_session
|
||||||
|
on public.training_plan_sessions(plan_id, week_no, session_no);
|
||||||
|
|
||||||
|
create or replace function public.create_training_plan_with_sessions(
|
||||||
|
p_pat_type text,
|
||||||
|
p_analysis_snapshot jsonb,
|
||||||
|
p_duration_weeks integer,
|
||||||
|
p_sessions_per_week integer,
|
||||||
|
p_sessions jsonb
|
||||||
|
)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user_id uuid;
|
||||||
|
v_plan_id uuid;
|
||||||
|
v_session jsonb;
|
||||||
|
v_week_no integer;
|
||||||
|
v_session_no integer;
|
||||||
|
begin
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
if v_user_id is null then
|
||||||
|
raise exception 'Not authenticated';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_duration_weeks not in (2, 4, 6) then
|
||||||
|
raise exception 'Invalid duration_weeks: %', p_duration_weeks;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_sessions_per_week not in (2, 3, 4) then
|
||||||
|
raise exception 'Invalid sessions_per_week: %', p_sessions_per_week;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.training_plans
|
||||||
|
set status = 'archived', archived_at = now()
|
||||||
|
where user_id = v_user_id
|
||||||
|
and pat_type = p_pat_type
|
||||||
|
and status = 'active';
|
||||||
|
|
||||||
|
insert into public.training_plans (
|
||||||
|
user_id,
|
||||||
|
pat_type,
|
||||||
|
status,
|
||||||
|
analysis_snapshot,
|
||||||
|
duration_weeks,
|
||||||
|
sessions_per_week,
|
||||||
|
generated_at
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_user_id,
|
||||||
|
p_pat_type,
|
||||||
|
'active',
|
||||||
|
coalesce(p_analysis_snapshot, '{}'::jsonb),
|
||||||
|
p_duration_weeks,
|
||||||
|
p_sessions_per_week,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
returning id into v_plan_id;
|
||||||
|
|
||||||
|
for v_session in
|
||||||
|
select value
|
||||||
|
from jsonb_array_elements(coalesce(p_sessions, '[]'::jsonb))
|
||||||
|
loop
|
||||||
|
v_week_no := coalesce((v_session->>'weekNo')::integer, (v_session->>'week_no')::integer, 1);
|
||||||
|
v_session_no := coalesce((v_session->>'sessionNo')::integer, (v_session->>'session_no')::integer, 1);
|
||||||
|
|
||||||
|
insert into public.training_plan_sessions (
|
||||||
|
plan_id,
|
||||||
|
week_no,
|
||||||
|
session_no,
|
||||||
|
main_exercise,
|
||||||
|
secondary_exercises,
|
||||||
|
tasks,
|
||||||
|
state,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_plan_id,
|
||||||
|
v_week_no,
|
||||||
|
v_session_no,
|
||||||
|
coalesce(v_session->>'mainExercise', v_session->>'main_exercise', 'Basistechnik'),
|
||||||
|
coalesce(v_session->'secondaryExercises', v_session->'secondary_exercises', '[]'::jsonb),
|
||||||
|
coalesce(v_session->'tasks', '[]'::jsonb),
|
||||||
|
coalesce(v_session->>'state', 'open'),
|
||||||
|
nullif(v_session->>'notes', '')
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return v_plan_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.create_training_plan_with_sessions(text, jsonb, integer, integer, jsonb)
|
||||||
|
to authenticated;
|
||||||
136
supabase/migrations/20260321000000_assessment_shares.sql
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
-- Token-based sharing for individual assessments
|
||||||
|
create table if not exists public.assessment_shares (
|
||||||
|
assessment_id uuid primary key references public.assessments(id) on delete cascade,
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
share_token text not null unique,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
drop trigger if exists trg_assessment_shares_updated_at on public.assessment_shares;
|
||||||
|
create trigger trg_assessment_shares_updated_at
|
||||||
|
before update on public.assessment_shares
|
||||||
|
for each row execute function public.set_updated_at();
|
||||||
|
|
||||||
|
alter table public.assessment_shares enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to insert own assessment shares" on public.assessment_shares;
|
||||||
|
create policy "Allow users to insert own assessment shares"
|
||||||
|
on public.assessment_shares
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
auth.uid() = user_id
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from public.assessments a
|
||||||
|
where a.id = assessment_shares.assessment_id
|
||||||
|
and a.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to select own assessment shares" on public.assessment_shares;
|
||||||
|
create policy "Allow users to select own assessment shares"
|
||||||
|
on public.assessment_shares
|
||||||
|
for select
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to update own assessment shares" on public.assessment_shares;
|
||||||
|
create policy "Allow users to update own assessment shares"
|
||||||
|
on public.assessment_shares
|
||||||
|
for update
|
||||||
|
using (auth.uid() = user_id)
|
||||||
|
with check (
|
||||||
|
auth.uid() = user_id
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from public.assessments a
|
||||||
|
where a.id = assessment_shares.assessment_id
|
||||||
|
and a.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "Allow users to delete own assessment shares" on public.assessment_shares;
|
||||||
|
create policy "Allow users to delete own assessment shares"
|
||||||
|
on public.assessment_shares
|
||||||
|
for delete
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create index if not exists idx_assessment_shares_user_id
|
||||||
|
on public.assessment_shares(user_id);
|
||||||
|
|
||||||
|
create or replace function public.generate_share_token()
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
as $$
|
||||||
|
select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.create_or_get_assessment_share(p_assessment_id uuid)
|
||||||
|
returns table (
|
||||||
|
assessment_id uuid,
|
||||||
|
share_token text
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user_id uuid;
|
||||||
|
begin
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
if v_user_id is null then
|
||||||
|
raise exception 'Not authenticated';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from public.assessments a
|
||||||
|
where a.id = p_assessment_id
|
||||||
|
and a.user_id = v_user_id
|
||||||
|
) then
|
||||||
|
raise exception 'Assessment not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.assessment_shares (assessment_id, user_id, share_token)
|
||||||
|
values (p_assessment_id, v_user_id, public.generate_share_token())
|
||||||
|
on conflict on constraint assessment_shares_pkey do nothing;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select s.assessment_id, s.share_token
|
||||||
|
from public.assessment_shares s
|
||||||
|
where s.assessment_id = p_assessment_id
|
||||||
|
and s.user_id = v_user_id
|
||||||
|
limit 1;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.get_shared_assessment(p_share_token text)
|
||||||
|
returns table (
|
||||||
|
id uuid,
|
||||||
|
pat_type text,
|
||||||
|
datum date,
|
||||||
|
name text,
|
||||||
|
exercises jsonb
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
a.id,
|
||||||
|
a.pat_type,
|
||||||
|
a.datum,
|
||||||
|
a.name,
|
||||||
|
a.exercises
|
||||||
|
from public.assessment_shares s
|
||||||
|
join public.assessments a on a.id = s.assessment_id
|
||||||
|
where s.share_token = p_share_token
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.create_or_get_assessment_share(uuid) from public;
|
||||||
|
grant execute on function public.create_or_get_assessment_share(uuid) to authenticated;
|
||||||
|
|
||||||
|
revoke all on function public.get_shared_assessment(text) from public;
|
||||||
|
grant execute on function public.get_shared_assessment(text) to anon, authenticated;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
alter table public.assessments
|
||||||
|
add column if not exists is_finalized boolean not null default false,
|
||||||
|
add column if not exists finalized_at timestamptz,
|
||||||
|
add column if not exists finalization_reminder_sent_at timestamptz;
|
||||||
|
|
||||||
|
create index if not exists idx_assessments_user_id_finalization
|
||||||
|
on public.assessments (user_id, is_finalized, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_assessments_open_finalization_reminders
|
||||||
|
on public.assessments (created_at)
|
||||||
|
where is_finalized = false and finalization_reminder_sent_at is null;
|
||||||
|
|
||||||
|
create or replace function public.guard_assessment_finalization()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'UPDATE' and old.is_finalized then
|
||||||
|
if new.pat_type is distinct from old.pat_type
|
||||||
|
or new.datum is distinct from old.datum
|
||||||
|
or new.name is distinct from old.name
|
||||||
|
or new.exercises is distinct from old.exercises
|
||||||
|
or new.is_finalized is distinct from old.is_finalized
|
||||||
|
or new.finalized_at is distinct from old.finalized_at then
|
||||||
|
raise exception 'Finalisierte Bewertungen koennen nicht mehr geaendert werden.';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if new.is_finalized then
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
new.finalized_at := coalesce(new.finalized_at, now());
|
||||||
|
else
|
||||||
|
new.finalized_at := coalesce(new.finalized_at, old.finalized_at, now());
|
||||||
|
end if;
|
||||||
|
new.finalization_reminder_sent_at := null;
|
||||||
|
else
|
||||||
|
new.finalized_at := null;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
drop trigger if exists trg_assessments_finalization_guard on public.assessments;
|
||||||
|
create trigger trg_assessments_finalization_guard
|
||||||
|
before insert or update on public.assessments
|
||||||
|
for each row execute function public.guard_assessment_finalization();
|
||||||
12
tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
7
unusedstuf/PAT-STATS.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
unusedstuf/Screenshot.png
Normal file
|
After Width: | Height: | Size: 840 KiB |
BIN
unusedstuf/Screenshot_1.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
unusedstuf/Screenshot_2.png
Normal file
|
After Width: | Height: | Size: 691 KiB |
BIN
unusedstuf/Screenshot_3.png
Normal file
|
After Width: | Height: | Size: 725 KiB |
BIN
unusedstuf/Screenshot_4.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
unusedstuf/doc00136420260301215306.pdf
Normal file
BIN
unusedstuf/doc00136620260301222813.pdf
Normal file
BIN
unusedstuf/page1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
unusedstuf/page1_rotated.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
unusedstuf/row1.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
unusedstuf/row2.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
unusedstuf/row3.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
unusedstuf/row4.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
unusedstuf/row5.png
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
unusedstuf/top_band.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
10
vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.{js,jsx}']
|
||||||
|
}
|
||||||
|
})
|
||||||