Übersetzung Teil 1

This commit is contained in:
Ashikagi
2026-03-23 23:02:51 +01:00
parent cd77f88d96
commit 46d468575a
21 changed files with 1605 additions and 333 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(supabase --version)"
]
}
}

View File

@@ -1,14 +1,21 @@
Offen Offen
nere Landing Page
Rangliste Rangliste
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
Max Punkte bei Bewertung PAT Start, Pat2 und Pat3
Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
2fa 2fa
Erledigt
Erledigt:
Final Speichern do Final Speichern do
Max Punkte bei Bewertung PAT1 Max Punkte bei Bewertung PAT1
User Profil mit Land und Sprach Auswahl
Termninal Termninal
vercel deploy vercel deploy
git add .
git commit -m "BenutzerProfil "
git push

View File

@@ -10,6 +10,8 @@ import ProfileTab from './components/ProfileTab'
import { useUserProfile } from './hooks/useUserProfile' import { useUserProfile } from './hooks/useUserProfile'
import { getCountryLabel, getCountryOption } from './data/countries' import { getCountryLabel, getCountryOption } from './data/countries'
import CountryFlag from './components/CountryFlag' import CountryFlag from './components/CountryFlag'
import { LanguageProvider } from './i18n/LanguageContext'
import { getTranslator } from './i18n/translations'
const readShareToken = () => { const readShareToken = () => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
@@ -44,6 +46,9 @@ function App() {
deleteProfileData deleteProfileData
} = useUserProfile(session) } = useUserProfile(session)
const language = profile?.language || 'de'
const t = getTranslator(language)
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
if (theme === 'dark') { if (theme === 'dark') {
@@ -144,16 +149,21 @@ function App() {
type="button" type="button"
> >
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
<span className="text-sm font-medium">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span> <span className="text-sm font-medium">{theme === 'dark' ? t('nav.theme_light') : t('nav.theme_dark')}</span>
</button> </button>
) )
if (shareToken) { if (shareToken) {
return <SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} /> return (
<LanguageProvider language={language}>
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
</LanguageProvider>
)
} }
if (!supabase) { if (!supabase) {
return ( return (
<LanguageProvider language={language}>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 flex items-center justify-center p-6"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 flex items-center justify-center p-6">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div> <div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<div className="max-w-xl w-full bg-white/90 dark:bg-gray-900/80 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl shadow-sm p-6"> <div className="max-w-xl w-full bg-white/90 dark:bg-gray-900/80 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl shadow-sm p-6">
@@ -168,40 +178,51 @@ function App() {
</p> </p>
</div> </div>
</div> </div>
</LanguageProvider>
) )
} }
if (loading) { if (loading) {
return ( return (
<LanguageProvider language={language}>
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div> <div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<span>Auth wird geladen...</span> <span>Auth wird geladen...</span>
</div> </div>
</LanguageProvider>
) )
} }
if (!session) { if (!session) {
if (showAuth) { if (showAuth) {
return <AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} /> return (
<LanguageProvider language={language}>
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
</LanguageProvider>
)
} }
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') { if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
return ( return (
<LanguageProvider language={language}>
<PublicInfoPage <PublicInfoPage
path={publicPath} path={publicPath}
onGetStarted={() => setShowAuth(true)} onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic} onNavigate={navigatePublic}
themeToggle={renderThemeToggle()} themeToggle={renderThemeToggle()}
/> />
</LanguageProvider>
) )
} }
return ( return (
<LanguageProvider language={language}>
<Landing <Landing
onGetStarted={() => setShowAuth(true)} onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic} onNavigate={navigatePublic}
themeToggle={renderThemeToggle()} themeToggle={renderThemeToggle()}
/> />
</LanguageProvider>
) )
} }
@@ -213,11 +234,12 @@ function App() {
const hasName = Boolean(userDisplayName) const hasName = Boolean(userDisplayName)
return ( return (
<LanguageProvider language={language}>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800"> <header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap"> <div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Angemeldet</p> <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('nav.logged_in')}</p>
<div className="flex items-center gap-2 flex-wrap text-sm font-semibold text-gray-800 dark:text-gray-100"> <div className="flex items-center gap-2 flex-wrap text-sm font-semibold text-gray-800 dark:text-gray-100">
{countryLabel && ( {countryLabel && (
<CountryFlag <CountryFlag
@@ -240,7 +262,7 @@ function App() {
: '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' : 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
}`} }`}
> >
Bewertungen {t('nav.assessments')}
</button> </button>
<button <button
@@ -252,9 +274,9 @@ function App() {
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100' ? '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' : 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`} } ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? 'Analyse ist während der Bearbeitung deaktiviert' : 'Analyse öffnen'} title={isEditingAssessment ? t('nav.analysis_disabled') : t('nav.analysis_open')}
> >
Analyse {t('nav.analysis')}
</button> </button>
<button <button
@@ -266,9 +288,9 @@ function App() {
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100' ? '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' : 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`} } ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? 'Profil ist während der Bearbeitung deaktiviert' : 'Profil öffnen'} title={isEditingAssessment ? t('nav.profile_disabled') : t('nav.profile_open')}
> >
Profil {t('nav.profile')}
</button> </button>
</nav> </nav>
@@ -278,7 +300,7 @@ function App() {
onClick={handleSignOut} onClick={handleSignOut}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition" className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition"
> >
Abmelden {t('nav.sign_out')}
</button> </button>
</div> </div>
</div> </div>
@@ -306,6 +328,7 @@ function App() {
/> />
)} )}
</div> </div>
</LanguageProvider>
) )
} }

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react';
import { Activity, AlertTriangle, Brain, Target } from 'lucide-react'; import { Activity, AlertTriangle, Brain, Target } from 'lucide-react';
import { buildAnalysis } from '../../utils/analysisEngine'; import { buildAnalysis } from '../../utils/analysisEngine';
import { useTrainingPlans } from '../../hooks/useTrainingPlans'; import { useTrainingPlans } from '../../hooks/useTrainingPlans';
import { useTranslation } from '../../i18n/LanguageContext';
import GapBarChart from './GapBarChart'; import GapBarChart from './GapBarChart';
import RadarChart from './RadarChart'; import RadarChart from './RadarChart';
import StrengthList from './StrengthList'; import StrengthList from './StrengthList';
@@ -16,6 +17,7 @@ export default function AnalysisTab({
patTypes, patTypes,
user user
}) { }) {
const t = useTranslation();
const patTypeOptions = Object.keys(patTypes || {}); const patTypeOptions = Object.keys(patTypes || {});
useEffect(() => { useEffect(() => {
@@ -65,10 +67,10 @@ export default function AnalysisTab({
<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"> <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 className="flex items-start justify-between gap-4 flex-wrap">
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">Analyse</p> <p className="text-xs uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">{t('analysis.title')}</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Wo solltest du besser werden?</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('analysis.priority_title')}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Priorisierung nach Gap × Faktor × Konstanz basierend auf den letzten 5 Assessments. {t('analysis.priority_desc')}
</p> </p>
</div> </div>
</div> </div>
@@ -96,18 +98,18 @@ export default function AnalysisTab({
<div className="mt-4 flex flex-wrap gap-2 text-xs"> <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"> <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" /> <Activity className="w-3 h-3 inline-block mr-1" />
Assessments: {selectedPatAssessments.length} {t('analysis.assessments_count', { count: selectedPatAssessments.length })}
</span> </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"> <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" /> <Target className="w-3 h-3 inline-block mr-1" />
Analysefenster: {analysis.windowedAssessments.length}/5 {t('analysis.window', { count: analysis.windowedAssessments.length })}
</span> </span>
{analysis.isLimitedData && ( {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"> <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" /> <AlertTriangle className="w-3 h-3 inline-block mr-1" />
Begrenzte Datenbasis {t('analysis.limited_data')}
</span> </span>
)} )}
</div> </div>
@@ -116,13 +118,13 @@ export default function AnalysisTab({
{!hasAssessments ? ( {!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"> <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" /> <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> <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('analysis.no_data')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">Lege zuerst eine Bewertung im Bereich "Bewertungen" an.</p> <p className="text-sm text-gray-600 dark:text-gray-300">{t('analysis.no_data_hint')}</p>
</section> </section>
) : selectedPatAssessments.length === 0 ? ( ) : 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"> <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> <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('analysis.no_type_data', { patType: 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> <p className="text-sm text-gray-600 dark:text-gray-300">{t('analysis.no_type_hint')}</p>
</section> </section>
) : ( ) : (
<> <>

View File

@@ -1,9 +1,12 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const truncate = (value, maxLength = 26) => const truncate = (value, maxLength = 26) =>
value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value; value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
export default function GapBarChart({ metrics = [] }) { export default function GapBarChart({ metrics = [] }) {
const t = useTranslation();
const data = [...metrics] const data = [...metrics]
.sort((left, right) => right.priorityScore - left.priorityScore) .sort((left, right) => right.priorityScore - left.priorityScore)
.slice(0, 8); .slice(0, 8);
@@ -11,7 +14,7 @@ export default function GapBarChart({ metrics = [] }) {
if (!data.length) { if (!data.length) {
return ( 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"> <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. {t('gapchart.no_data')}
</div> </div>
); );
} }
@@ -29,8 +32,8 @@ export default function GapBarChart({ metrics = [] }) {
return ( 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"> <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> <h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">{t('gapchart.title')}</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Gap-Balkendiagramm"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label={t('gapchart.aria')}>
{data.map((item, index) => { {data.map((item, index) => {
const y = topPad + index * rowHeight; const y = topPad + index * rowHeight;
const barWidth = (item.priorityScore / maxScore) * chartWidth; const barWidth = (item.priorityScore / maxScore) * chartWidth;

View File

@@ -1,19 +1,22 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100); const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function StrengthList({ strengths = [] }) { export default function StrengthList({ strengths = [] }) {
const t = useTranslation();
if (!strengths.length) { if (!strengths.length) {
return ( 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"> <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. {t('strength.no_data')}
</div> </div>
); );
} }
return ( return (
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-900 bg-white dark:bg-gray-900 p-5 shadow-sm"> <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> <h3 className="text-lg font-semibold text-emerald-700 dark:text-emerald-300 mb-4">{t('strength.title')}</h3>
<div className="space-y-3"> <div className="space-y-3">
{strengths.map((item, index) => ( {strengths.map((item, index) => (
@@ -30,9 +33,9 @@ export default function StrengthList({ strengths = [] }) {
</span> </span>
</div> </div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300"> <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>{t('strength.actual', { score: item.actualMean.toFixed(2) })}</p>
<p>Soll: {item.soll.toFixed(2)}</p> <p>{t('strength.target', { score: item.soll.toFixed(2) })}</p>
<p>Trend: {item.trendDelta.toFixed(2)}</p> <p>{t('strength.trend', { score: item.trendDelta.toFixed(2) })}</p>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { generateTrainingPlan } from '../../utils/trainingPlanGenerator'; import { generateTrainingPlan } from '../../utils/trainingPlanGenerator';
import { useTranslation } from '../../i18n/LanguageContext';
const formatDateTime = (value) => { const formatDateTime = (value) => {
if (!value) return '—'; if (!value) return '—';
@@ -48,6 +49,7 @@ export default function TrainingPlanPanel({
onUpdateSessionPartial, onUpdateSessionPartial,
onReload onReload
}) { }) {
const t = useTranslation();
const [durationWeeks, setDurationWeeks] = useState(4); const [durationWeeks, setDurationWeeks] = useState(4);
const [sessionsPerWeek, setSessionsPerWeek] = useState(3); const [sessionsPerWeek, setSessionsPerWeek] = useState(3);
const [draftSessions, setDraftSessions] = useState([]); const [draftSessions, setDraftSessions] = useState([]);
@@ -72,7 +74,7 @@ export default function TrainingPlanPanel({
}); });
setDraftSessions(generated.sessions); setDraftSessions(generated.sessions);
setUiMessage('Neuer Entwurf erstellt. Du kannst jetzt Intensität und Übungen je Session anpassen.'); setUiMessage(t('training.draft_created'));
}; };
const updateDraftSession = (index, patch) => { const updateDraftSession = (index, patch) => {
@@ -96,7 +98,7 @@ export default function TrainingPlanPanel({
const handleSaveDraft = async () => { const handleSaveDraft = async () => {
if (!draftSessions.length) { if (!draftSessions.length) {
setUiMessage('Erstelle zuerst einen Entwurf.'); setUiMessage(t('training.create_draft_first'));
return; return;
} }
@@ -108,11 +110,11 @@ export default function TrainingPlanPanel({
}); });
if (!result?.ok) { if (!result?.ok) {
setUiMessage(result?.error?.message || 'Trainingsplan konnte nicht gespeichert werden.'); setUiMessage(result?.error?.message || t('training.save_error'));
return; return;
} }
setUiMessage('Trainingsplan gespeichert. Der vorherige aktive Plan wurde archiviert.'); setUiMessage(t('training.save_success'));
setDraftSessions([]); setDraftSessions([]);
}; };
@@ -123,7 +125,7 @@ export default function TrainingPlanPanel({
<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 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"> <div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-sky-800 dark:text-sky-200"> <p className="text-sm font-semibold text-sky-800 dark:text-sky-200">
Woche {session.weekNo} · Einheit {session.sessionNo} {t('training.week_session', { weekNo: session.weekNo, sessionNo: session.sessionNo })}
</p> </p>
<span className="text-xs text-sky-700 dark:text-sky-300">{session.intensity}</span> <span className="text-xs text-sky-700 dark:text-sky-300">{session.intensity}</span>
</div> </div>
@@ -133,7 +135,7 @@ export default function TrainingPlanPanel({
value={session.mainExercise} value={session.mainExercise}
onChange={(event) => updateDraftSession(index, { mainExercise: event.target.value })} 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" className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
placeholder="Hauptübung" placeholder={t('training.main_exercise')}
/> />
<input <input
@@ -153,7 +155,7 @@ export default function TrainingPlanPanel({
}); });
}} }}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900" 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" placeholder={t('training.side_exercises')}
/> />
<select <select
@@ -161,9 +163,9 @@ export default function TrainingPlanPanel({
onChange={(event) => updateDraftSession(index, { intensity: event.target.value })} 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" 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="hoch">{t('training.high')}</option>
<option value="mittel">mittel</option> <option value="mittel">{t('training.medium')}</option>
<option value="leicht">leicht</option> <option value="leicht">{t('training.easy')}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -174,9 +176,9 @@ export default function TrainingPlanPanel({
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-5 shadow-sm"> <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 className="flex items-start justify-between flex-wrap gap-3 mb-4">
<div> <div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Trainingsplan ({patType})</h3> <h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">{t('training.title', { patType })}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
1 aktiver Plan + Historie, Session-Status: offen / erledigt / übersprungen. {t('training.hint')}
</p> </p>
</div> </div>
@@ -185,7 +187,7 @@ export default function TrainingPlanPanel({
type="button" 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" 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 {t('training.reload')}
</button> </button>
</div> </div>
@@ -197,20 +199,20 @@ export default function TrainingPlanPanel({
<div className="grid md:grid-cols-4 gap-3 mb-4"> <div className="grid md:grid-cols-4 gap-3 mb-4">
<label className="text-sm text-gray-700 dark:text-gray-200"> <label className="text-sm text-gray-700 dark:text-gray-200">
Dauer {t('training.duration')}
<select <select
value={durationWeeks} value={durationWeeks}
onChange={(event) => setDurationWeeks(Number(event.target.value))} 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" 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={2}>{t('training.2weeks')}</option>
<option value={4}>4 Wochen</option> <option value={4}>{t('training.4weeks')}</option>
<option value={6}>6 Wochen</option> <option value={6}>{t('training.6weeks')}</option>
</select> </select>
</label> </label>
<label className="text-sm text-gray-700 dark:text-gray-200"> <label className="text-sm text-gray-700 dark:text-gray-200">
Einheiten/Woche {t('training.sessions_per_week')}
<select <select
value={sessionsPerWeek} value={sessionsPerWeek}
onChange={(event) => setSessionsPerWeek(Number(event.target.value))} onChange={(event) => setSessionsPerWeek(Number(event.target.value))}
@@ -227,7 +229,7 @@ export default function TrainingPlanPanel({
type="button" type="button"
className="md:self-end px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700" className="md:self-end px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"
> >
Plan (neu) generieren {t('training.generate')}
</button> </button>
<button <button
@@ -236,7 +238,7 @@ export default function TrainingPlanPanel({
disabled={saving || !draftSessions.length} 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" 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'} {saving ? t('training.saving') : t('training.save_draft')}
</button> </button>
</div> </div>
@@ -249,9 +251,9 @@ export default function TrainingPlanPanel({
{draftSessions.length > 0 && ( {draftSessions.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-2"> <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> <h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('training.draft_label')}</h4>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
Sessions: {draftSummary.total} · hoch {draftSummary.high} · mittel {draftSummary.medium} · leicht {draftSummary.low} {t('training.sessions_info', { count: draftSummary.total, high: draftSummary.high, medium: draftSummary.medium, easy: draftSummary.low })}
</p> </p>
</div> </div>
@@ -261,16 +263,16 @@ export default function TrainingPlanPanel({
<div className="grid lg:grid-cols-2 gap-4"> <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"> <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> <h4 className="font-semibold text-emerald-800 dark:text-emerald-200 mb-2">{t('training.active_plan')}</h4>
{loading ? ( {loading ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Lädt</p> <p className="text-sm text-gray-600 dark:text-gray-300">{t('training.loading')}</p>
) : !activePlan ? ( ) : !activePlan ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Noch kein aktiver Trainingsplan vorhanden.</p> <p className="text-sm text-gray-600 dark:text-gray-300">{t('training.no_active')}</p>
) : ( ) : (
<> <>
<p className="text-xs text-gray-600 dark:text-gray-300 mb-2"> <p className="text-xs text-gray-600 dark:text-gray-300 mb-2">
Generiert: {formatDateTime(activePlan.generatedAt)} · {activePlan.durationWeeks} Wochen · {activePlan.sessionsPerWeek} Einheiten/Woche {t('training.generated', { date: formatDateTime(activePlan.generatedAt), weeks: activePlan.durationWeeks, sessions: activePlan.sessionsPerWeek })}
</p> </p>
<div className="max-h-80 overflow-y-auto space-y-2 pr-1"> <div className="max-h-80 overflow-y-auto space-y-2 pr-1">
@@ -278,7 +280,7 @@ export default function TrainingPlanPanel({
<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 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"> <div className="flex items-center justify-between gap-2 mb-2">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100"> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
W{session.weekNo} · E{session.sessionNo} {t('training.week_short', { weekNo: session.weekNo, sessionNo: session.sessionNo })}
</p> </p>
<div className="flex gap-1"> <div className="flex gap-1">
{['open', 'done', 'skipped'].map((state) => ( {['open', 'done', 'skipped'].map((state) => (
@@ -288,7 +290,7 @@ export default function TrainingPlanPanel({
onClick={() => onUpdateSessionState(session.id, state)} onClick={() => onUpdateSessionState(session.id, state)}
className={`text-xs px-2 py-1 border rounded ${statusButtonClass(session.state === state)}`} className={`text-xs px-2 py-1 border rounded ${statusButtonClass(session.state === state)}`}
> >
{state === 'open' ? 'offen' : state === 'done' ? 'erledigt' : 'übersprungen'} {state === 'open' ? t('training.status_open') : state === 'done' ? t('training.status_done') : t('training.status_skipped')}
</button> </button>
))} ))}
</div> </div>
@@ -321,7 +323,7 @@ export default function TrainingPlanPanel({
<textarea <textarea
defaultValue={session.notes || ''} defaultValue={session.notes || ''}
onBlur={(event) => onUpdateSessionPartial(session.id, { notes: event.target.value })} onBlur={(event) => onUpdateSessionPartial(session.id, { notes: event.target.value })}
placeholder="Notiz" placeholder={t('training.note')}
className="w-full px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900" 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} rows={2}
/> />
@@ -333,10 +335,10 @@ export default function TrainingPlanPanel({
</div> </div>
<div className="rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-gray-50 dark:bg-gray-950/40"> <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> <h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('training.history')}</h4>
{!historyPlans.length ? ( {!historyPlans.length ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Keine archivierten Pläne vorhanden.</p> <p className="text-sm text-gray-600 dark:text-gray-300">{t('training.no_history')}</p>
) : ( ) : (
<div className="space-y-2 max-h-80 overflow-y-auto pr-1"> <div className="space-y-2 max-h-80 overflow-y-auto pr-1">
{historyPlans.map((plan) => ( {historyPlans.map((plan) => (

View File

@@ -1,19 +1,22 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100); const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function WeaknessPriorityList({ weaknesses = [] }) { export default function WeaknessPriorityList({ weaknesses = [] }) {
const t = useTranslation();
if (!weaknesses.length) { if (!weaknesses.length) {
return ( 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"> <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. {t('weakness.no_data')}
</div> </div>
); );
} }
return ( return (
<div className="rounded-2xl border border-rose-200 dark:border-rose-900 bg-white dark:bg-gray-900 p-5 shadow-sm"> <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> <h3 className="text-lg font-semibold text-rose-700 dark:text-rose-300 mb-4">{t('weakness.title')}</h3>
<div className="space-y-3"> <div className="space-y-3">
{weaknesses.map((item, index) => ( {weaknesses.map((item, index) => (
@@ -30,9 +33,9 @@ export default function WeaknessPriorityList({ weaknesses = [] }) {
</span> </span>
</div> </div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300"> <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>{t('weakness.gap', { score: item.gapScore.toFixed(2) })}</p>
<p>Konstanz: {item.consistencyScore.toFixed(2)}</p> <p>{t('weakness.consistency', { score: item.consistencyScore.toFixed(2) })}</p>
<p>Trend: {item.trendDelta.toFixed(2)}</p> <p>{t('weakness.trend', { score: item.trendDelta.toFixed(2) })}</p>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { supabase } from '../lib/supabaseClient' import { supabase } from '../lib/supabaseClient'
import { useTranslation } from '../i18n/LanguageContext'
export default function AuthPanel({ onAuth, themeToggle, onBack }) { export default function AuthPanel({ onAuth, themeToggle, onBack }) {
const t = useTranslation()
const [mode, setMode] = useState('login') const [mode, setMode] = useState('login')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -27,7 +29,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
if (signInError) { if (signInError) {
setError(signInError.message) setError(signInError.message)
} else { } else {
setMessage('Erfolgreich angemeldet') setMessage(t('auth.login_success'))
onAuth?.(data.session) onAuth?.(data.session)
} }
} else { } else {
@@ -38,7 +40,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
if (signUpError) { if (signUpError) {
setError(signUpError.message) setError(signUpError.message)
} else if (!data.session) { } else if (!data.session) {
setMessage('Registrierung abgeschlossen. Prüfe dein Postfach zur Bestätigung.') setMessage(t('auth.register_success'))
} else { } else {
onAuth?.(data.session) onAuth?.(data.session)
} }
@@ -60,17 +62,17 @@ export default function AuthPanel({ onAuth, themeToggle, 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" 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" /> <ArrowLeft className="h-4 w-4" />
Zurück {t('auth.back')}
</button> </button>
)} )}
</div> </div>
<div className="absolute top-4 right-4">{themeToggle}</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="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"> <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> <p className="text-sm uppercase tracking-wide text-indigo-600 dark:text-indigo-300 font-semibold">{t('auth.title')}</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">Anmelden oder registrieren</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">{t('auth.subtitle')}</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2"> <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. {t('auth.description')}
</p> </p>
</div> </div>
@@ -81,19 +83,19 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
onClick={() => setMode('login')} 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'}`} 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 {t('auth.login_tab')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setMode('register')} 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'}`} 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 {t('auth.register_tab')}
</button> </button>
</div> </div>
<label className="block"> <label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">E-Mail</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('auth.email')}</span>
<input <input
type="email" type="email"
value={email} value={email}
@@ -105,7 +107,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
</label> </label>
<label className="block"> <label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Passwort</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('auth.password')}</span>
<input <input
type="password" type="password"
value={password} value={password}
@@ -133,7 +135,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
disabled={loading} 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" 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'} {loading ? t('auth.sending') : mode === 'login' ? t('auth.login_btn') : t('auth.register_btn')}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Activity, BarChart3, Clock, TrendingUp } from 'lucide-react' import { Activity, BarChart3, Clock, TrendingUp } from 'lucide-react'
import { calculateTotal } from '../utils/patCalculations' import { calculateTotal } from '../utils/patCalculations'
import { useTranslation } from '../i18n/LanguageContext'
const formatDate = (value) => { const formatDate = (value) => {
if (!value) return '—' if (!value) return '—'
@@ -10,6 +11,8 @@ const formatDate = (value) => {
} }
export default function LiveOverview({ assessments, loading = false, error }) { export default function LiveOverview({ assessments, loading = false, error }) {
const t = useTranslation()
const stats = useMemo(() => { const stats = useMemo(() => {
if (loading || !assessments) { if (loading || !assessments) {
return { return {
@@ -54,12 +57,12 @@ export default function LiveOverview({ assessments, loading = false, error }) {
<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="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 className="flex items-center justify-between flex-wrap gap-3 mb-4">
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-300">Live Übersicht</p> <p className="text-xs uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-300">{t('live.title')}</p>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">Aktuelle Statistik</h2> <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">{t('live.stats_title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Basis: alle Bewertungen, geladen beim Seitenaufruf</p> <p className="text-sm text-gray-600 dark:text-gray-400">{t('live.basis')}</p>
</div> </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"> <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'} {loading ? t('live.loading') : t('live.timestamp')}
</span> </span>
</div> </div>
@@ -77,35 +80,35 @@ export default function LiveOverview({ assessments, loading = false, error }) {
</div> </div>
) : stats.total === 0 ? ( ) : 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"> <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. {t('live.no_data')}
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <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="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"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-200">Bewertungen</p> <p className="text-sm font-semibold text-emerald-700 dark:text-emerald-200">{t('live.count')}</p>
<Activity className="h-4 w-4 text-emerald-600 dark:text-emerald-300" aria-hidden="true" /> <Activity className="h-4 w-4 text-emerald-600 dark:text-emerald-300" aria-hidden="true" />
</div> </div>
<p className="mt-3 text-3xl font-bold text-emerald-800 dark:text-emerald-100">{stats.total}</p> <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> <p className="text-xs text-emerald-700/80 dark:text-emerald-200/70">{t('live.count_desc')}</p>
</div> </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="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"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-sky-700 dark:text-sky-200">Ø Gesamtpunkte</p> <p className="text-sm font-semibold text-sky-700 dark:text-sky-200">{t('live.avg_points')}</p>
<TrendingUp className="h-4 w-4 text-sky-600 dark:text-sky-300" aria-hidden="true" /> <TrendingUp className="h-4 w-4 text-sky-600 dark:text-sky-300" aria-hidden="true" />
</div> </div>
<p className="mt-3 text-3xl font-bold text-sky-800 dark:text-sky-100">{stats.average}</p> <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> <p className="text-xs text-sky-700/80 dark:text-sky-200/70">{t('live.avg_desc')}</p>
</div> </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="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"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">Letzte Bewertung</p> <p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">{t('live.last')}</p>
<Clock className="h-4 w-4 text-indigo-600 dark:text-indigo-300" aria-hidden="true" /> <Clock className="h-4 w-4 text-indigo-600 dark:text-indigo-300" aria-hidden="true" />
</div> </div>
<p className="mt-3 text-lg font-semibold text-indigo-900 dark:text-indigo-100"> <p className="mt-3 text-lg font-semibold text-indigo-900 dark:text-indigo-100">
{stats.latest?.name || stats.latest?.patType || 'Unbenannt'} {stats.latest?.name || stats.latest?.patType || t('live.unnamed')}
</p> </p>
<p className="text-xs text-indigo-700/80 dark:text-indigo-200/70"> <p className="text-xs text-indigo-700/80 dark:text-indigo-200/70">
{formatDate(stats.latest?.datum)} · {stats.latest?.patType || 'PAT'} {formatDate(stats.latest?.datum)} · {stats.latest?.patType || 'PAT'}
@@ -114,7 +117,7 @@ export default function LiveOverview({ assessments, loading = false, error }) {
<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="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"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-200">Top Ergebnis</p> <p className="text-sm font-semibold text-amber-700 dark:text-amber-200">{t('live.top')}</p>
<BarChart3 className="h-4 w-4 text-amber-600 dark:text-amber-300" aria-hidden="true" /> <BarChart3 className="h-4 w-4 text-amber-600 dark:text-amber-300" aria-hidden="true" />
</div> </div>
<p className="mt-3 text-3xl font-bold text-amber-800 dark:text-amber-100"> <p className="mt-3 text-3xl font-bold text-amber-800 dark:text-amber-100">

View File

@@ -3,6 +3,7 @@ import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
import ExerciseInput from './ExerciseInput'; import ExerciseInput from './ExerciseInput';
import SaveDialog from './SaveDialog'; import SaveDialog from './SaveDialog';
import { downloadAssessmentPdf } from '../../utils/pdfExport'; import { downloadAssessmentPdf } from '../../utils/pdfExport';
import { useTranslation } from '../../i18n/LanguageContext';
const PatDetail = ({ const PatDetail = ({
currentAssessment, currentAssessment,
@@ -30,6 +31,8 @@ const PatDetail = ({
onShare, onShare,
onRevokeShare onRevokeShare
}) => { }) => {
const t = useTranslation();
if (!currentAssessment) return null; if (!currentAssessment) return null;
const totalPoints = calculateTotal(); const totalPoints = calculateTotal();
@@ -59,11 +62,11 @@ const PatDetail = ({
onClick={onBack} onClick={onBack}
className="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 font-medium" className="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 font-medium"
> >
Zurück zur Übersicht {t('detail.back')}
</button> </button>
{hasUnsavedChanges && ( {hasUnsavedChanges && (
<span className="text-orange-500 dark:text-orange-300 text-sm font-semibold"> <span className="text-orange-500 dark:text-orange-300 text-sm font-semibold">
Ungespeicherte Änderungen {t('detail.unsaved')}
</span> </span>
)} )}
<span <span
@@ -79,12 +82,12 @@ const PatDetail = ({
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200' ? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}> }`}>
{testsVisible ? 'Freigabe aktiv' : 'Freigabe pausiert'} {testsVisible ? t('detail.share_active') : t('detail.share_paused')}
</span> </span>
)} )}
{currentAssessment?.isFinalized && ( {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"> <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 {t('detail.finalized')}
</span> </span>
)} )}
</div> </div>
@@ -96,7 +99,7 @@ const PatDetail = ({
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center gap-2 text-sm" 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" /> <Save className="w-4 h-4" aria-hidden="true" />
Speichern {t('detail.save')}
</button> </button>
<button <button
onClick={onFinalize} onClick={onFinalize}
@@ -109,7 +112,7 @@ const PatDetail = ({
}`} }`}
> >
<CheckCircle2 className="w-4 h-4" aria-hidden="true" /> <CheckCircle2 className="w-4 h-4" aria-hidden="true" />
Final Abschließen {t('detail.finalize')}
</button> </button>
</> </>
)} )}
@@ -118,7 +121,7 @@ const PatDetail = ({
className="bg-amber-600 text-white px-4 py-2 rounded-lg hover:bg-amber-700 transition flex items-center gap-2 text-sm" 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" /> <FileDown className="w-4 h-4" aria-hidden="true" />
PDF {t('detail.pdf')}
</button> </button>
<button <button
onClick={onShare} onClick={onShare}
@@ -131,14 +134,14 @@ const PatDetail = ({
}`} }`}
> >
<Share2 className="w-4 h-4" aria-hidden="true" /> <Share2 className="w-4 h-4" aria-hidden="true" />
Teilen {t('detail.share')}
</button> </button>
{isShareActive && ( {isShareActive && (
<button <button
onClick={onRevokeShare} 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" 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 {t('detail.unshare')}
</button> </button>
)} )}
</div> </div>
@@ -148,16 +151,16 @@ const PatDetail = ({
<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="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"> <div className="flex items-center gap-2 font-semibold">
<Lock className="h-4 w-4" aria-hidden="true" /> <Lock className="h-4 w-4" aria-hidden="true" />
Nur Lese-Modus {t('detail.readonly')}
</div> </div>
<p className="mt-1 text-sm"> <p className="mt-1 text-sm">
Diese Bewertung wurde final abgeschlossen und kann nicht mehr bearbeitet werden. {t('detail.readonly_hint')}
</p> </p>
</div> </div>
)} )}
<div className="grid grid-cols-4 gap-2 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded"> <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 className="font-bold">{t('detail.date_name')}</div>
<div></div> <div></div>
<input <input
type="date" type="date"
@@ -179,7 +182,7 @@ const PatDetail = ({
onAssessmentChange({ ...currentAssessment, name: e.target.value }); onAssessmentChange({ ...currentAssessment, name: e.target.value });
onMarkDirty(); onMarkDirty();
}} }}
placeholder="Name" placeholder={t('detail.name_placeholder')}
className={`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-900 ${ 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' : '' isReadOnly ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
}`} }`}
@@ -193,19 +196,19 @@ const PatDetail = ({
<ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} /> <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="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">{t('detail.col_avg_target', { soll: exercise.soll })}</div>
<div className="font-semibold">Ist</div> <div className="font-semibold">{t('detail.col_actual')}</div>
<div className="font-bold text-center">{exercise.durchschnitt.toFixed(2)}</div> <div className="font-bold text-center">{exercise.durchschnitt.toFixed(2)}</div>
</div> </div>
<div className="grid grid-cols-5 gap-2 mb-1"> <div className="grid grid-cols-5 gap-2 mb-1">
<div>Faktor</div> <div>{t('detail.col_factor')}</div>
<div></div> <div></div>
<div className="text-center font-semibold">{exercise.faktor}</div> <div className="text-center font-semibold">{exercise.faktor}</div>
</div> </div>
<div className="grid grid-cols-5 gap-2 bg-green-50 dark:bg-green-900/30 p-2 rounded"> <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 className="font-bold">{t('detail.col_points')}</div>
<div></div> <div></div>
<div className="text-center font-bold text-green-600 dark:text-green-300">{exercise.points.toFixed(0)}</div> <div className="text-center font-bold text-green-600 dark:text-green-300">{exercise.points.toFixed(0)}</div>
</div> </div>
@@ -214,7 +217,7 @@ const PatDetail = ({
<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="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"> <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-2xl font-bold text-gray-800 dark:text-gray-100">{t('detail.col_result')}</span>
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span> <span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div> </div>

View File

@@ -1,31 +1,34 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const SaveDialog = ({ show, onSave, onDiscard, onCancel }) => { const SaveDialog = ({ show, onSave, onDiscard, onCancel }) => {
const t = useTranslation();
if (!show) return null; if (!show) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <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> <h3 className="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4">{t('savedialog.title')}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-6">Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern?</p> <p className="text-gray-600 dark:text-gray-300 mb-6">{t('savedialog.message')}</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onSave} onClick={onSave}
className="flex-1 bg-green-600 text-white px-4 py-3 rounded-lg hover:bg-green-700 transition font-semibold" className="flex-1 bg-green-600 text-white px-4 py-3 rounded-lg hover:bg-green-700 transition font-semibold"
> >
Ja, speichern {t('savedialog.yes')}
</button> </button>
<button <button
onClick={onDiscard} onClick={onDiscard}
className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition font-semibold" className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition font-semibold"
> >
Nein, verwerfen {t('savedialog.no')}
</button> </button>
<button <button
onClick={onCancel} 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" 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 {t('savedialog.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -10,8 +10,7 @@ import {
Trash2, Trash2,
User User
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '../../i18n/LanguageContext';
const CALENDAR_DAY_LABELS = ['M', 'D', 'M', 'D', 'F', 'S', 'S'];
const toIsoDate = (date) => { const toIsoDate = (date) => {
const year = date.getFullYear(); const year = date.getFullYear();
@@ -56,6 +55,7 @@ const PatList = ({
getPatTypeColor, getPatTypeColor,
getAchievement getAchievement
}) => { }) => {
const t = useTranslation();
const datePickerRef = useRef(null); const datePickerRef = useRef(null);
const [showCreateMenu, setShowCreateMenu] = useState(false); const [showCreateMenu, setShowCreateMenu] = useState(false);
const [activeDatePicker, setActiveDatePicker] = useState(null); const [activeDatePicker, setActiveDatePicker] = useState(null);
@@ -217,7 +217,7 @@ const PatList = ({
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/70"> <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> <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"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Anzahl: {entries.length} {t('patlist.count', { count: entries.length })}
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''} {entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
</p> </p>
</div> </div>
@@ -236,14 +236,14 @@ const PatList = ({
onClick={() => handleSort('name')} onClick={() => handleSort('name')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition" className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
> >
Name {t('patlist.col_name')}
{renderSortIcon('name')} {renderSortIcon('name')}
</button> </button>
<input <input
type="text" type="text"
value={filters.name} value={filters.name}
onChange={(event) => handleFilterChange('name', event.target.value)} onChange={(event) => handleFilterChange('name', event.target.value)}
aria-label="Nach Name filtern" aria-label={t('patlist.filter_name')}
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" 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> </div>
@@ -255,18 +255,18 @@ const PatList = ({
onClick={() => handleSort('datum')} onClick={() => handleSort('datum')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition" className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
> >
Datum {t('patlist.col_date')}
{renderSortIcon('datum')} {renderSortIcon('datum')}
</button> </button>
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative"> <div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
<button <button
type="button" type="button"
onClick={() => toggleDatePicker(tableKey)} onClick={() => toggleDatePicker(tableKey)}
aria-label="Nach Datum filtern" aria-label={t('patlist.filter_date')}
aria-expanded={isDatePickerOpen} 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" 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> <span>{formatDateForFilter(filters.datum) || t('patlist.date_placeholder')}</span>
<Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" /> <Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
</button> </button>
@@ -281,7 +281,7 @@ const PatList = ({
) )
} }
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" 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" aria-label={t('patlist.prev_month')}
> >
<ChevronLeft className="h-4 w-4" aria-hidden="true" /> <ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button> </button>
@@ -296,14 +296,14 @@ const PatList = ({
) )
} }
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" 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" aria-label={t('patlist.next_month')}
> >
<ChevronRight className="h-4 w-4" aria-hidden="true" /> <ChevronRight className="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </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"> <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) => ( {t('patlist.days').split(' ').map((label, index) => (
<span key={`${label}-${index}`}>{label}</span> <span key={`${label}-${index}`}>{label}</span>
))} ))}
</div> </div>
@@ -337,14 +337,14 @@ const PatList = ({
onClick={selectToday} 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" 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 {t('patlist.today')}
</button> </button>
<button <button
type="button" type="button"
onClick={clearDate} 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" 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 {t('patlist.delete')}
</button> </button>
</div> </div>
</div> </div>
@@ -359,7 +359,7 @@ const PatList = ({
onClick={() => handleSort('result')} onClick={() => handleSort('result')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition" className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
> >
Ergebnis {t('patlist.col_result')}
{renderSortIcon('result')} {renderSortIcon('result')}
</button> </button>
</div> </div>
@@ -401,12 +401,12 @@ const PatList = ({
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200' ? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}> }`}>
{testsVisible ? 'Freigabe aktiv' : 'Freigabe pausiert'} {testsVisible ? t('patlist.share_active') : t('patlist.share_paused')}
</span> </span>
)} )}
{assessment.isFinalized && ( {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"> <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 {t('patlist.finalized')}
</span> </span>
)} )}
</div> </div>
@@ -433,7 +433,7 @@ const PatList = ({
onClick={() => onEdit(assessment)} onClick={() => onEdit(assessment)}
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm" className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm"
> >
Öffnen {t('patlist.open')}
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -441,7 +441,7 @@ const PatList = ({
onDelete(assessment.id); onDelete(assessment.id);
}} }}
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-2" className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-2"
title="Löschen" title={t('patlist.delete')}
> >
<Trash2 className="w-5 h-5" aria-hidden="true" /> <Trash2 className="w-5 h-5" aria-hidden="true" />
</button> </button>
@@ -462,15 +462,15 @@ const PatList = ({
<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="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="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="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"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">PAT Test Manager</h1> <h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">{t('patlist.title')}</h1>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowCreateMenu((current) => !current)} 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" 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" /> <Plus className="w-4 h-4" />
Neue Bewertung {t('patlist.new_assessment')}
</button> </button>
{showCreateMenu && ( {showCreateMenu && (
@@ -501,17 +501,17 @@ const PatList = ({
<div className="space-y-6"> <div className="space-y-6">
{renderAssessmentTable( {renderAssessmentTable(
'open', 'open',
'Noch offene Bewertungen', t('patlist.open_assessments'),
sortedOpenAssessments, sortedOpenAssessments,
openAssessments.length, openAssessments.length,
'Keine offenen Bewertungen mit den aktuellen Suchfiltern gefunden' t('patlist.no_open')
)} )}
{renderAssessmentTable( {renderAssessmentTable(
'finalized', 'finalized',
'Abgeschlossene Bewertungen', t('patlist.closed_assessments'),
sortedFinalizedAssessments, sortedFinalizedAssessments,
finalizedAssessments.length, finalizedAssessments.length,
'Keine abgeschlossenen Bewertungen mit den aktuellen Suchfiltern gefunden' t('patlist.no_closed')
)} )}
</div> </div>
)} )}
@@ -519,8 +519,8 @@ const PatList = ({
{assessments.length === 0 && ( {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"> <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" /> <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-gray-600 dark:text-gray-300 mb-4">{t('patlist.no_assessments')}</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> <p className="text-sm text-gray-500 dark:text-gray-400">{t('patlist.no_assessments_hint')}</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../i18n/LanguageContext';
import PatList from './PatList/PatList'; import PatList from './PatList/PatList';
import PatDetail from './PatDetail/PatDetail'; import PatDetail from './PatDetail/PatDetail';
import AnalysisTab from './Analysis/AnalysisTab'; import AnalysisTab from './Analysis/AnalysisTab';
@@ -32,6 +33,7 @@ export default function PatTestManager({
testsVisible = false, testsVisible = false,
isProfileLoading = false isProfileLoading = false
}) { }) {
const t = useTranslation();
const visiblePatTypes = useMemo(() => patTypes || {}, []); const visiblePatTypes = useMemo(() => patTypes || {}, []);
const patTypeOptions = Object.keys(visiblePatTypes); const patTypeOptions = Object.keys(visiblePatTypes);
const [showList, setShowList] = useState(true); const [showList, setShowList] = useState(true);
@@ -151,7 +153,7 @@ export default function PatTestManager({
const executeSave = async () => { const executeSave = async () => {
const result = await handleSave(); const result = await handleSave();
if (result?.ok) { if (result?.ok) {
alert('Erfolgreich gespeichert!'); alert(t('patmgr.saved'));
} }
return result; return result;
}; };
@@ -159,13 +161,13 @@ export default function PatTestManager({
const handleFinalize = useCallback(async () => { const handleFinalize = useCallback(async () => {
const result = await finalizeAssessment(); const result = await finalizeAssessment();
if (result?.ok) { if (result?.ok) {
alert('Bewertung wurde final abgeschlossen und ist jetzt nur noch lesbar.'); alert(t('patmgr.finalized'));
} }
}, [finalizeAssessment]); }, [finalizeAssessment]);
const handleSaveAndExit = async () => { const handleSaveAndExit = async () => {
if (!currentAssessment?.name || !currentAssessment?.datum) { if (!currentAssessment?.name || !currentAssessment?.datum) {
alert('Bitte Name und Datum ausfüllen bevor Sie speichern'); alert(t('patmgr.fill_name_date'));
return; return;
} }
@@ -190,7 +192,7 @@ export default function PatTestManager({
const { token, url } = await createAssessmentShareLink(assessment); const { token, url } = await createAssessmentShareLink(assessment);
const label = assessment?.name || assessment?.patType || 'dieser Test'; const label = assessment?.name || assessment?.patType || 'dieser Test';
const localhostHint = isLocalhostShare() const localhostHint = isLocalhostShare()
? '\n\nHinweis: Der Link zeigt aktuell auf localhost und ist nur erreichbar, wenn die App von außen erreichbar ist.' ? `\n\n${t('patmgr.share_localhost_hint')}`
: ''; : '';
setAssessmentShareTokens((prev) => ({ setAssessmentShareTokens((prev) => ({
...prev, ...prev,
@@ -200,16 +202,16 @@ export default function PatTestManager({
try { try {
const copied = await copyToClipboard(url); const copied = await copyToClipboard(url);
if (copied) { if (copied) {
alert(`Freigabelink für "${label}" wurde in die Zwischenablage kopiert.${localhostHint}`); alert(`${t('patmgr.share_copied', { label })}${localhostHint}`);
return; return;
} }
} catch (clipboardError) { } catch (clipboardError) {
console.warn('Kopieren in die Zwischenablage fehlgeschlagen:', clipboardError); console.warn('Kopieren in die Zwischenablage fehlgeschlagen:', clipboardError);
} }
window.prompt(`Freigabelink für "${label}"${localhostHint}`, url); window.prompt(`${t('patmgr.share_link_label', { label })}${localhostHint}`, url);
} catch (error) { } catch (error) {
alert(`Freigabelink konnte nicht erstellt werden: ${error.message || error}`); alert(t('patmgr.share_error', { error: error.message || error }));
} }
}, []); }, []);
const handleRevokeShare = useCallback(async (assessment) => { const handleRevokeShare = useCallback(async (assessment) => {
@@ -222,31 +224,31 @@ export default function PatTestManager({
delete next[assessment.id]; delete next[assessment.id];
return next; return next;
}); });
alert(`Freigabe für "${label}" wurde aufgehoben.`); alert(t('patmgr.unshare_success', { label }));
} catch (error) { } catch (error) {
alert(`Freigabe konnte nicht aufgehoben werden: ${error.message || error}`); alert(t('patmgr.unshare_error', { error: error.message || error }));
} }
}, []); }, []);
const handleShareFromDetail = useCallback(() => { const handleShareFromDetail = useCallback(() => {
if (!currentAssessment) return; if (!currentAssessment) return;
if (isProfileLoading) { if (isProfileLoading) {
alert('Profil wird noch geladen. Bitte versuche es gleich noch einmal.'); alert(t('patmgr.profile_loading'));
return; return;
} }
if (!testsVisible) { if (!testsVisible) {
alert('Aktiviere im Profil zuerst "Tests sichtbar", bevor du Tests teilst.'); alert(t('patmgr.enable_visible'));
return; return;
} }
if (!isCurrentAssessmentPersisted) { if (!isCurrentAssessmentPersisted) {
alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.'); alert(t('patmgr.save_before_share'));
return; return;
} }
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
alert('Bitte erst speichern, damit der aktuelle Stand geteilt wird.'); alert(t('patmgr.save_for_share'));
return; return;
} }
@@ -264,13 +266,13 @@ export default function PatTestManager({
handleRevokeShare(currentAssessment); handleRevokeShare(currentAssessment);
}, [currentAssessment, handleRevokeShare, isCurrentAssessmentShareActive]); }, [currentAssessment, handleRevokeShare, isCurrentAssessmentShareActive]);
const shareDisabledReason = !isCurrentAssessmentPersisted const shareDisabledReason = !isCurrentAssessmentPersisted
? 'Bitte zuerst speichern' ? t('patmgr.save_first')
: hasUnsavedChanges : hasUnsavedChanges
? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird' ? t('patmgr.save_for_share')
: isProfileLoading : isProfileLoading
? 'Profil wird geladen' ? t('patmgr.profile_loading_btn')
: !testsVisible : !testsVisible
? 'Aktiviere im Profil "Tests sichtbar"' ? t('patmgr.enable_visible_btn')
: ''; : '';
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment); const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries'; import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
import CountryFlag from './CountryFlag'; import CountryFlag from './CountryFlag';
import { useTranslation } from '../i18n/LanguageContext';
const emptyStatus = { const emptyStatus = {
message: '', message: '',
@@ -17,6 +18,7 @@ export default function ProfileTab({
onDelete, onDelete,
onSessionChange onSessionChange
}) { }) {
const t = useTranslation();
const [formValues, setFormValues] = useState(profile); const [formValues, setFormValues] = useState(profile);
const [status, setStatus] = useState(emptyStatus); const [status, setStatus] = useState(emptyStatus);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -87,14 +89,14 @@ export default function ProfileTab({
setStatus({ setStatus({
message: result.emailUpdateRequested message: result.emailUpdateRequested
? 'Profil gespeichert. Die neue Mailadresse muss ggf. noch per E-Mail bestätigt werden.' ? t('profile.saved_email')
: 'Profil gespeichert.', : t('profile.saved'),
error: '' error: ''
}); });
} else { } else {
setStatus({ setStatus({
message: '', message: '',
error: result?.error?.message || 'Profil konnte nicht gespeichert werden.' error: result?.error?.message || t('profile.error_save')
}); });
} }
@@ -102,9 +104,7 @@ export default function ProfileTab({
}; };
const handleDelete = async () => { const handleDelete = async () => {
const confirmed = window.confirm( const confirmed = window.confirm(t('profile.delete_confirm'));
'Profil wirklich löschen?\n\nDabei werden alle Tests dieses Benutzers sowie vorhandene Analysepläne dauerhaft entfernt.'
);
if (!confirmed) return; if (!confirmed) return;
@@ -115,13 +115,13 @@ export default function ProfileTab({
if (result?.ok) { if (result?.ok) {
setStatus({ setStatus({
message: 'Profil und alle Tests wurden gelöscht. Das Konto bleibt bestehen und kann neu eingerichtet werden.', message: t('profile.deleted'),
error: '' error: ''
}); });
} else { } else {
setStatus({ setStatus({
message: '', message: '',
error: result?.error?.message || 'Profil konnte nicht gelöscht werden.' error: result?.error?.message || t('profile.error_delete')
}); });
} }
@@ -133,44 +133,44 @@ export default function ProfileTab({
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 overflow-hidden"> <div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-800"> <div className="px-6 py-5 border-b border-gray-200 dark:border-gray-800">
<p className="text-xs uppercase tracking-[0.25em] text-sky-700 dark:text-sky-300">Benutzerprofil</p> <p className="text-xs uppercase tracking-[0.25em] text-sky-700 dark:text-sky-300">{t('profile.title_label')}</p>
<h1 className="text-3xl font-bold mt-2">Profil verwalten</h1> <h1 className="text-3xl font-bold mt-2">{t('profile.title')}</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300"> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Name, Vorname, Mailadresse, Land und die Sichtbarkeit deiner Testfreigaben werden hier gepflegt. {t('profile.description')}
</p> </p>
</div> </div>
{loading ? ( {loading ? (
<div className="px-6 py-10 text-sm text-gray-600 dark:text-gray-300"> <div className="px-6 py-10 text-sm text-gray-600 dark:text-gray-300">
Profil wird geladen... {t('profile.loading')}
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="px-6 py-6 space-y-6"> <form onSubmit={handleSubmit} className="px-6 py-6 space-y-6">
<div className="grid md:grid-cols-2 gap-5"> <div className="grid md:grid-cols-2 gap-5">
<label className="block"> <label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Vorname</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.first_name')}</span>
<input <input
type="text" type="text"
value={formValues.firstName || ''} value={formValues.firstName || ''}
onChange={updateField('firstName')} onChange={updateField('firstName')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500" className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="Max" placeholder={t('profile.first_name_placeholder')}
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Nachname</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.last_name')}</span>
<input <input
type="text" type="text"
value={formValues.lastName || ''} value={formValues.lastName || ''}
onChange={updateField('lastName')} onChange={updateField('lastName')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500" className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="Mustermann" placeholder={t('profile.last_name_placeholder')}
/> />
</label> </label>
<label className="block md:col-span-2"> <label className="block md:col-span-2">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Mailadresse</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.email')}</span>
<input <input
type="email" type="email"
value={formValues.email || session?.user?.email || ''} value={formValues.email || session?.user?.email || ''}
@@ -180,12 +180,12 @@ export default function ProfileTab({
placeholder="you@example.com" placeholder="you@example.com"
/> />
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400"> <span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
Wenn du die Mailadresse änderst, wird dir eine Bestätigung an die neue Adresse gesendet. {t('profile.email_hint')}
</span> </span>
</label> </label>
<label className="block"> <label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Land</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.country')}</span>
<div ref={countryDropdownRef} className="relative mt-1"> <div ref={countryDropdownRef} className="relative mt-1">
<button <button
type="button" type="button"
@@ -195,11 +195,11 @@ export default function ProfileTab({
<span className="inline-flex items-center gap-3"> <span className="inline-flex items-center gap-3">
<CountryFlag <CountryFlag
countryCode={selectedCountry?.code || formValues.country} countryCode={selectedCountry?.code || formValues.country}
title={selectedCountry?.name || getCountryLabel(formValues.country) || 'Land auswählen'} title={selectedCountry?.name || getCountryLabel(formValues.country) || t('profile.country_none')}
className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10" className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10"
/> />
<span className="font-medium"> <span className="font-medium">
{selectedCountry?.name || getCountryLabel(formValues.country) || 'Land auswählen'} {selectedCountry?.name || getCountryLabel(formValues.country) || t('profile.country_none')}
</span> </span>
</span> </span>
<ChevronDown <ChevronDown
@@ -222,10 +222,10 @@ export default function ProfileTab({
<span className="inline-flex items-center gap-3"> <span className="inline-flex items-center gap-3">
<CountryFlag <CountryFlag
countryCode="" countryCode=""
title="Kein Land ausgewählt" title={t('profile.country_none')}
className="h-5 w-7 rounded-[3px] shadow-sm" className="h-5 w-7 rounded-[3px] shadow-sm"
/> />
<span>Kein Land ausgewählt</span> <span>{t('profile.country_none')}</span>
</span> </span>
{!formValues.country && <Check className="h-4 w-4" aria-hidden="true" />} {!formValues.country && <Check className="h-4 w-4" aria-hidden="true" />}
</button> </button>
@@ -278,35 +278,52 @@ export default function ProfileTab({
)} )}
</div> </div>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400"> <span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
Die ausgewählte Flagge wird später auch im Kopfbereich neben deiner Mailadresse angezeigt. {t('profile.country_hint')}
</span> </span>
</label> </label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.display')}</span>
<select
value={formValues.language || 'de'}
onChange={updateField('language')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<option value="de">Deutsch</option>
<option value="en">Englisch</option>
<option value="it">Italienisch</option>
<option value="es">Spanisch</option>
<option value="pt">Portugiesisch</option>
</select>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
{t('profile.display_hint')}
</span>
</label>
</div>
<div className="block"> <div className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Ranglistenplatz</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.rank')}</span>
<div className="mt-1 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/30"> <div className="mt-1 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/30">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-amber-800 dark:text-amber-200"> <span className="text-sm font-medium text-amber-800 dark:text-amber-200">
Platzhalter {t('profile.rank_placeholder')}
</span> </span>
<span className="rounded-full bg-white px-3 py-1 text-sm font-semibold text-amber-700 shadow-sm dark:bg-gray-900 dark:text-amber-300"> <span className="rounded-full bg-white px-3 py-1 text-sm font-semibold text-amber-700 shadow-sm dark:bg-gray-900 dark:text-amber-300">
# - # -
</span> </span>
</div> </div>
<p className="mt-2 text-xs text-amber-700/80 dark:text-amber-200/80"> <p className="mt-2 text-xs text-amber-700/80 dark:text-amber-200/80">
Hier kann spater der Ranglistenplatz des Benutzers angezeigt werden. {t('profile.rank_hint')}
</p> </p>
</div> </div>
</div> </div>
</div>
<div className="rounded-xl border border-sky-200 dark:border-sky-900/70 bg-sky-50 dark:bg-sky-950/30 p-5"> <div className="rounded-xl border border-sky-200 dark:border-sky-900/70 bg-sky-50 dark:bg-sky-950/30 p-5">
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div> <div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Tests sichtbar</h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t('profile.tests_visible_title')}</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 max-w-2xl"> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
Ist dieser Schalter aktiv, funktionieren deine Freigabelinks. Wenn du ihn deaktivierst, {t('profile.tests_visible_desc')}
bleiben vorhandene Links gespeichert, sind aber nicht mehr öffentlich sichtbar.
</p> </p>
</div> </div>
@@ -318,7 +335,7 @@ export default function ProfileTab({
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/> />
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100"> <span className="text-sm font-semibold text-gray-800 dark:text-gray-100">
{formValues.testsVisible ? 'Sichtbar' : 'Nicht sichtbar'} {formValues.testsVisible ? t('profile.visible') : t('profile.not_visible')}
</span> </span>
</label> </label>
</div> </div>
@@ -338,7 +355,7 @@ export default function ProfileTab({
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
Konto-ID: <span className="font-mono">{session?.user?.id || '—'}</span> {t('profile.account_id')}: <span className="font-mono">{session?.user?.id || '—'}</span>
</div> </div>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@@ -348,7 +365,7 @@ export default function ProfileTab({
disabled={deleting || saving} disabled={deleting || saving}
className="px-5 py-3 rounded-lg border border-rose-300 bg-white text-rose-700 font-semibold hover:bg-rose-50 transition disabled:opacity-60 disabled:cursor-not-allowed dark:border-rose-800 dark:bg-gray-900 dark:text-rose-300 dark:hover:bg-rose-950/40" className="px-5 py-3 rounded-lg border border-rose-300 bg-white text-rose-700 font-semibold hover:bg-rose-50 transition disabled:opacity-60 disabled:cursor-not-allowed dark:border-rose-800 dark:bg-gray-900 dark:text-rose-300 dark:hover:bg-rose-950/40"
> >
{deleting ? 'Löscht...' : 'Profil löschen'} {deleting ? t('profile.deleting') : t('profile.delete')}
</button> </button>
<button <button
@@ -356,7 +373,7 @@ export default function ProfileTab({
disabled={saving || deleting} disabled={saving || deleting}
className="px-5 py-3 rounded-lg bg-sky-600 text-white font-semibold hover:bg-sky-700 transition disabled:opacity-60 disabled:cursor-not-allowed" className="px-5 py-3 rounded-lg bg-sky-600 text-white font-semibold hover:bg-sky-700 transition disabled:opacity-60 disabled:cursor-not-allowed"
> >
{saving ? 'Speichert...' : 'Profil speichern'} {saving ? t('profile.saving') : t('profile.save')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import SharedAssessmentView from './SharedAssessmentView' import SharedAssessmentView from './SharedAssessmentView'
import { getSharedAssessment } from '../lib/assessmentShareService' import { getSharedAssessment } from '../lib/assessmentShareService'
import { useTranslation } from '../i18n/LanguageContext'
export default function SharedAssessmentPage({ shareToken, themeToggle }) { export default function SharedAssessmentPage({ shareToken, themeToggle }) {
const t = useTranslation()
const [assessment, setAssessment] = useState(null) const [assessment, setAssessment] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -12,7 +14,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
const loadAssessment = async () => { const loadAssessment = async () => {
if (!shareToken) { if (!shareToken) {
setError('Freigabelink fehlt.') setError(t('shared.missing_token'))
setLoading(false) setLoading(false)
return return
} }
@@ -27,7 +29,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
if (!sharedAssessment) { if (!sharedAssessment) {
setAssessment(null) setAssessment(null)
setError('Dieser Freigabelink ist ungültig, deaktiviert oder aktuell nicht öffentlich sichtbar.') setError(t('shared.invalid'))
return return
} }
@@ -35,7 +37,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
} catch (err) { } catch (err) {
if (ignore) return if (ignore) return
setAssessment(null) setAssessment(null)
setError(err.message || 'Geteilter Test konnte nicht geladen werden.') setError(err.message || t('shared.load_error'))
} finally { } finally {
if (!ignore) { if (!ignore) {
setLoading(false) setLoading(false)
@@ -57,14 +59,14 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
{loading ? ( {loading ? (
<div className="min-h-screen flex items-center justify-center p-6"> <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"> <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... {t('shared.loading')}
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<div className="min-h-screen flex items-center justify-center p-6"> <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"> <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> <p className="text-xs uppercase tracking-[0.2em] text-rose-600 dark:text-rose-300">{t('shared.label')}</p>
<h1 className="text-2xl font-bold mt-2">Geteilter Test nicht verfügbar</h1> <h1 className="text-2xl font-bold mt-2">{t('shared.not_available')}</h1>
<p className="text-gray-600 dark:text-gray-300 mt-3">{error}</p> <p className="text-gray-600 dark:text-gray-300 mt-3">{error}</p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { calculateTotal, getAchievement, getPatTypeColor } from '../utils/patCalculations' import { calculateTotal, getAchievement, getPatTypeColor } from '../utils/patCalculations'
import { useTranslation } from '../i18n/LanguageContext'
const formatDate = (value) => { const formatDate = (value) => {
if (!value) return '—' if (!value) return '—'
@@ -28,6 +29,8 @@ const ValueRow = ({ label, values = [] }) => (
) )
export default function SharedAssessmentView({ assessment }) { export default function SharedAssessmentView({ assessment }) {
const t = useTranslation()
if (!assessment) return null if (!assessment) return null
const totalPoints = calculateTotal(assessment.exercises || []) const totalPoints = calculateTotal(assessment.exercises || [])
@@ -39,12 +42,12 @@ export default function SharedAssessmentView({ assessment }) {
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800"> <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 className="flex justify-between items-start gap-4 mb-6 flex-wrap">
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-sky-700 dark:text-sky-300">Geteilter Test</p> <p className="text-xs uppercase tracking-[0.2em] text-sky-700 dark:text-sky-300">{t('shared.title')}</p>
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mt-2"> <h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mt-2">
{assessment.name || 'PAT Test'} {assessment.name || t('shared.default_name')}
</h1> </h1>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2"> <p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
Dieser Link zeigt nur diesen einen Test im Nur-Lesen-Modus. {t('shared.hint')}
</p> </p>
</div> </div>
@@ -57,17 +60,17 @@ export default function SharedAssessmentView({ assessment }) {
<div className="grid md:grid-cols-3 gap-3 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg"> <div className="grid md:grid-cols-3 gap-3 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</p> <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.date')}</p>
<p className="mt-1 text-lg font-semibold">{formatDate(assessment.datum)}</p> <p className="mt-1 text-lg font-semibold">{formatDate(assessment.datum)}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Gesamtpunkte</p> <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.total_points')}</p>
<p className="mt-1 text-lg font-semibold text-green-600 dark:text-green-300"> <p className="mt-1 text-lg font-semibold text-green-600 dark:text-green-300">
{totalPoints.toFixed(0)} {totalPoints.toFixed(0)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewertung</p> <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.rating')}</p>
<p className="mt-1 text-lg font-semibold">{achievement.name}</p> <p className="mt-1 text-lg font-semibold">{achievement.name}</p>
</div> </div>
</div> </div>
@@ -78,11 +81,11 @@ export default function SharedAssessmentView({ assessment }) {
<div> <div>
<h2 className="font-bold text-lg text-gray-700 dark:text-gray-200">{exercise.name}</h2> <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"> <p className="text-sm text-gray-500 dark:text-gray-400">
Soll {exercise.soll} · Faktor {exercise.faktor} {t('shared.target_factor', { soll: exercise.soll, faktor: exercise.faktor })}
</p> </p>
</div> </div>
<div className="rounded-lg bg-green-50 dark:bg-green-900/30 px-4 py-2"> <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-xs uppercase tracking-wide text-green-700 dark:text-green-300">{t('shared.col_points')}</p>
<p className="text-xl font-bold text-green-700 dark:text-green-200"> <p className="text-xl font-bold text-green-700 dark:text-green-200">
{(exercise.points || 0).toFixed(0)} {(exercise.points || 0).toFixed(0)}
</p> </p>
@@ -96,23 +99,23 @@ export default function SharedAssessmentView({ assessment }) {
<ValueRow label={exercise.subLabels?.[1] || 'b)'} values={exercise.subB} /> <ValueRow label={exercise.subLabels?.[1] || 'b)'} values={exercise.subB} />
</> </>
) : ( ) : (
<ValueRow label="Werte" values={exercise.values || []} /> <ValueRow label={t('shared.col_values')} values={exercise.values || []} />
)} )}
</div> </div>
<div className="grid md:grid-cols-3 gap-3 mt-4"> <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"> <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-xs uppercase tracking-wide text-blue-700 dark:text-blue-300">{t('shared.col_avg')}</p>
<p className="text-lg font-semibold text-blue-700 dark:text-blue-200"> <p className="text-lg font-semibold text-blue-700 dark:text-blue-200">
{(exercise.durchschnitt || 0).toFixed(2)} {(exercise.durchschnitt || 0).toFixed(2)}
</p> </p>
</div> </div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3"> <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-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.col_target')}</p>
<p className="text-lg font-semibold">{exercise.soll}</p> <p className="text-lg font-semibold">{exercise.soll}</p>
</div> </div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3"> <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-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.col_factor')}</p>
<p className="text-lg font-semibold">{exercise.faktor}</p> <p className="text-lg font-semibold">{exercise.faktor}</p>
</div> </div>
</div> </div>
@@ -121,7 +124,7 @@ export default function SharedAssessmentView({ assessment }) {
<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="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"> <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-2xl font-bold text-gray-800 dark:text-gray-100">{t('shared.col_result')}</span>
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span> <span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div> </div>

View File

@@ -7,6 +7,7 @@ const buildDefaultProfile = (email = '') => ({
firstName: '', firstName: '',
lastName: '', lastName: '',
country: '', country: '',
language: 'de',
testsVisible: false, testsVisible: false,
email email
}); });
@@ -15,6 +16,7 @@ const mapFromDb = (row, email = '') => ({
firstName: row?.first_name || '', firstName: row?.first_name || '',
lastName: row?.last_name || '', lastName: row?.last_name || '',
country: row?.country || '', country: row?.country || '',
language: row?.language || 'de',
testsVisible: Boolean(row?.tests_visible), testsVisible: Boolean(row?.tests_visible),
email email
}); });
@@ -24,6 +26,7 @@ const mapToDb = (profile, userId) => ({
first_name: profile.firstName.trim(), first_name: profile.firstName.trim(),
last_name: profile.lastName.trim(), last_name: profile.lastName.trim(),
country: profile.country.trim(), country: profile.country.trim(),
language: profile.language || 'de',
tests_visible: Boolean(profile.testsVisible) tests_visible: Boolean(profile.testsVisible)
}); });
@@ -62,7 +65,7 @@ export const useUserProfile = (session) => {
try { try {
const { data, error: loadError } = await supabase const { data, error: loadError } = await supabase
.from('user_profiles') .from('user_profiles')
.select('first_name, last_name, country, tests_visible') .select('first_name, last_name, country, language, tests_visible')
.eq('user_id', userId) .eq('user_id', userId)
.maybeSingle(); .maybeSingle();
@@ -112,7 +115,7 @@ export const useUserProfile = (session) => {
const { data, error: saveError } = await supabase const { data, error: saveError } = await supabase
.from('user_profiles') .from('user_profiles')
.upsert(mapToDb(nextValues, userId), { onConflict: 'user_id' }) .upsert(mapToDb(nextValues, userId), { onConflict: 'user_id' })
.select('first_name, last_name, country, tests_visible') .select('first_name, last_name, country, language, tests_visible')
.single(); .single();
if (saveError) throw saveError; if (saveError) throw saveError;

View File

@@ -0,0 +1,18 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getTranslator } from './translations';
const LanguageContext = createContext(() => (key) => key);
export function LanguageProvider({ language, children }) {
const t = useMemo(() => getTranslator(language), [language]);
return (
<LanguageContext.Provider value={t}>
{children}
</LanguageContext.Provider>
);
}
export function useTranslation() {
return useContext(LanguageContext);
}

1164
src/i18n/translations.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
alter table public.user_profiles
add column if not exists language text not null default 'de';