Initial commit
This commit is contained in:
164
src/components/Analysis/AnalysisTab.jsx
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user