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}']
|
||||
}
|
||||
})
|
||||