Übersetzung Teil 1
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(supabase --version)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
43
src/App.jsx
43
src/App.jsx
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
18
src/i18n/LanguageContext.jsx
Normal file
18
src/i18n/LanguageContext.jsx
Normal 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
1164
src/i18n/translations.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
alter table public.user_profiles
|
||||||
|
add column if not exists language text not null default 'de';
|
||||||
Reference in New Issue
Block a user