Files
fotospiel-app/resources/js/admin/pages/SettingsPage.tsx
2025-11-07 13:50:55 +01:00

379 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { AlertTriangle, LogOut, Palette, UserCog } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Button } from '@/components/ui/button';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AdminLayout } from '../components/AdminLayout';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
} from '../components/tenant';
import { useAuth } from '../auth/context';
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH, ADMIN_PROFILE_PATH } from '../constants';
import { encodeReturnTo } from '../lib/returnTo';
import {
getNotificationPreferences,
updateNotificationPreferences,
NotificationPreferences,
NotificationPreferencesMeta,
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { useTranslation } from 'react-i18next';
export default function SettingsPage() {
const navigate = useNavigate();
const { user, logout } = useAuth();
const { t } = useTranslation('management');
const [preferences, setPreferences] = React.useState<NotificationPreferences | null>(null);
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
const [savingNotifications, setSavingNotifications] = React.useState(false);
const [notificationError, setNotificationError] = React.useState<string | null>(null);
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
const heroDescription = t('settings.hero.description', { defaultValue: 'Gestalte das Erlebnis für dein Admin-Team Darstellung, Benachrichtigungen und Session-Sicherheit.' });
const heroSupporting = [
t('settings.hero.summary.appearance', { defaultValue: 'Synchronisiere den Look & Feel mit dem Gästeportal oder schalte den Dark Mode frei.' }),
t('settings.hero.summary.notifications', { defaultValue: 'Stimme Benachrichtigungen auf Aufgaben, Pakete und Live-Events ab.' })
];
const accountName = user?.name ?? user?.email ?? 'Tenant Admin';
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(ADMIN_PROFILE_PATH)}
>
{t('settings.hero.actions.profile', 'Profil bearbeiten')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
>
{t('settings.hero.actions.events', 'Zur Event-Übersicht')}
</Button>
);
const heroAside = (
<FrostedSurface className="space-y-3 border-slate-200 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">{t('settings.hero.accountLabel', { defaultValue: 'Angemeldeter Account' })}</p>
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{accountName}</p>
{user?.tenant_id ? (
<p className="text-xs text-slate-600 dark:text-slate-400">Tenant #{user.tenant_id}</p>
) : null}
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('settings.hero.support', { defaultValue: 'Passe Einstellungen für dich und dein Team an Änderungen wirken sofort im Admin.' })}</p>
</FrostedSurface>
);
const translateNotification = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
function handleLogout() {
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
target.searchParams.set('reset-auth', '1');
target.searchParams.set('return_to', encodeReturnTo(ADMIN_EVENTS_PATH));
logout({ redirect: `${target.pathname}${target.search}` });
}
React.useEffect(() => {
(async () => {
try {
const result = await getNotificationPreferences();
setPreferences(result.preferences);
setDefaults(result.defaults);
setNotificationMeta(result.meta ?? null);
} catch (error) {
setNotificationError(getApiErrorMessage(error, t('settings.notifications.errorLoad', 'Benachrichtigungseinstellungen konnten nicht geladen werden.')));
} finally {
setLoadingNotifications(false);
}
})();
}, [t]);
return (
<AdminLayout
title="Einstellungen"
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
>
<TenantHeroCard
badge={t('settings.hero.badge', { defaultValue: 'Administration' })}
title={t('settings.title', { defaultValue: 'Einstellungen' })}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<SectionCard className="mt-6 max-w-2xl space-y-6">
<SectionHeader
eyebrow={t('settings.appearance.badge', 'Darstellung & Account')}
title={t('settings.appearance.title', 'Darstellung & Account')}
description={t('settings.appearance.description', 'Gestalte den Admin-Bereich so farbenfroh wie dein Gästeportal.')}
/>
<section className="space-y-2">
<h2 className="text-sm font-semibold text-slate-800">Darstellung</h2>
<p className="text-sm text-slate-600">
Wechsel zwischen Hell- und Dunkelmodus oder übernimm automatisch die Systemeinstellung.
</p>
<AppearanceToggleDropdown />
</section>
<section className="space-y-2">
<h2 className="text-sm font-semibold text-slate-800">Angemeldeter Account</h2>
<p className="text-sm text-slate-600">
{user ? (
<>
Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Tenant Admin'}</span>
{user.tenant_id && <> - Tenant #{user.tenant_id}</>}
</>
) : (
'Aktuell kein Benutzer geladen.'
)}
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Button variant="destructive" onClick={handleLogout} className="flex items-center gap-2">
<LogOut className="h-4 w-4" /> Abmelden
</Button>
<Button variant="secondary" onClick={() => navigate(ADMIN_PROFILE_PATH)} className="flex items-center gap-2">
<UserCog className="h-4 w-4" /> {t('settings.profile.actions.openProfile', 'Profil bearbeiten')}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>
Abbrechen
</Button>
</div>
</section>
</SectionCard>
<SectionCard className="max-w-3xl space-y-6">
<SectionHeader
eyebrow={t('settings.notifications.badge', 'Benachrichtigungen')}
title={t('settings.notifications.title', 'Benachrichtigungen')}
description={t('settings.notifications.description', 'Lege fest, für welche Ereignisse wir dich per E-Mail informieren.')}
/>
{notificationError && (
<Alert variant="destructive">
<AlertDescription>{notificationError}</AlertDescription>
</Alert>
)}
{loadingNotifications ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<FrostedSurface
key={index}
className="h-12 animate-pulse border border-white/20 bg-gradient-to-r from-white/30 via-white/60 to-white/30 shadow-inner dark:border-slate-800/60 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800"
/>
))}
</div>
) : preferences ? (
<NotificationPreferencesForm
preferences={preferences}
defaults={defaults}
meta={notificationMeta}
onChange={(next) => setPreferences(next)}
onReset={() => setPreferences(defaults)}
onSave={async () => {
if (!preferences) {
return;
}
try {
setSavingNotifications(true);
const updated = await updateNotificationPreferences(preferences);
setPreferences(updated.preferences);
if (updated.defaults && Object.keys(updated.defaults).length > 0) {
setDefaults(updated.defaults);
}
if (updated.meta) {
setNotificationMeta(updated.meta);
}
setNotificationError(null);
} catch (error) {
setNotificationError(
getApiErrorMessage(error, t('settings.notifications.errorSave', 'Speichern fehlgeschlagen. Bitte versuche es erneut.')),
);
} finally {
setSavingNotifications(false);
}
}}
saving={savingNotifications}
translate={translateNotification}
/>
) : null}
</SectionCard>
</AdminLayout>
);
}
function NotificationPreferencesForm({
preferences,
defaults,
meta,
onChange,
onReset,
onSave,
saving,
translate,
}: {
preferences: NotificationPreferences;
defaults: NotificationPreferences;
meta: NotificationPreferencesMeta | null;
onChange: (next: NotificationPreferences) => void;
onReset: () => void;
onSave: () => Promise<void>;
saving: boolean;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
}) {
const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
const creditText = React.useMemo(() => {
if (!meta) {
return null;
}
if (meta.credit_warning_sent_at) {
const date = formatDateTime(meta.credit_warning_sent_at, locale);
return translate('settings.notifications.meta.creditLast', 'Letzte Credit-Warnung: {{date}}', {
date,
});
}
return translate('settings.notifications.meta.creditNever', 'Noch keine Credit-Warnung versendet.');
}, [meta, translate, locale]);
return (
<div className="space-y-4">
<div className="space-y-3">
{items.map((item) => {
const checked = preferences[item.key] ?? defaults[item.key] ?? true;
return (
<FrostedSurface key={item.key} className="flex items-start justify-between gap-4 border border-slate-200 p-4 text-slate-900 shadow-sm shadow-rose-200/20 dark:border-pink-500/20 dark:bg-white/10">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{item.label}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
/>
</FrostedSurface>
);
})}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={onSave} disabled={saving} className="bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-md shadow-rose-400/30">
{saving ? 'Speichern...' : translate('settings.notifications.actions.save', 'Speichern')}
</Button>
<Button variant="ghost" onClick={onReset} disabled={saving}>
{translate('settings.notifications.actions.reset', 'Auf Standard setzen')}
</Button>
<span className="text-xs text-slate-500 dark:text-slate-400">
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
</span>
</div>
{creditText ? <p className="text-xs text-slate-500 dark:text-slate-400">{creditText}</p> : null}
</div>
);
}
function buildPreferenceMeta(
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string
): Array<{ key: keyof NotificationPreferences; label: string; description: string }> {
const map = [
{
key: 'photo_thresholds',
label: translate('settings.notifications.items.photoThresholds.label', 'Warnung bei Foto-Schwellen'),
description: translate('settings.notifications.items.photoThresholds.description', 'Sende Warnungen bei 80 % und 95 % Foto-Auslastung.'),
},
{
key: 'photo_limits',
label: translate('settings.notifications.items.photoLimits.label', 'Sperre bei Foto-Limit'),
description: translate('settings.notifications.items.photoLimits.description', 'Informiere mich, sobald keine Foto-Uploads mehr möglich sind.'),
},
{
key: 'guest_thresholds',
label: translate('settings.notifications.items.guestThresholds.label', 'Warnung bei Gästekontingent'),
description: translate('settings.notifications.items.guestThresholds.description', 'Warnung kurz bevor alle Gästelinks vergeben sind.'),
},
{
key: 'guest_limits',
label: translate('settings.notifications.items.guestLimits.label', 'Sperre bei Gästelimit'),
description: translate('settings.notifications.items.guestLimits.description', 'Hinweis, wenn keine neuen Gästelinks mehr erzeugt werden können.'),
},
{
key: 'gallery_warnings',
label: translate('settings.notifications.items.galleryWarnings.label', 'Galerie läuft bald ab'),
description: translate('settings.notifications.items.galleryWarnings.description', 'Erhalte 7 und 1 Tag vor Ablauf eine Erinnerung.'),
},
{
key: 'gallery_expired',
label: translate('settings.notifications.items.galleryExpired.label', 'Galerie ist abgelaufen'),
description: translate('settings.notifications.items.galleryExpired.description', 'Informiere mich, sobald Gäste die Galerie nicht mehr sehen können.'),
},
{
key: 'event_thresholds',
label: translate('settings.notifications.items.eventThresholds.label', 'Warnung bei Event-Kontingent'),
description: translate('settings.notifications.items.eventThresholds.description', 'Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist.'),
},
{
key: 'event_limits',
label: translate('settings.notifications.items.eventLimits.label', 'Sperre bei Event-Kontingent'),
description: translate('settings.notifications.items.eventLimits.description', 'Nachricht, sobald keine weiteren Events erstellt werden können.'),
},
{
key: 'package_expiring',
label: translate('settings.notifications.items.packageExpiring.label', 'Paket läuft bald ab'),
description: translate('settings.notifications.items.packageExpiring.description', 'Erinnerungen bei 30, 7 und 1 Tag vor Paketablauf.'),
},
{
key: 'package_expired',
label: translate('settings.notifications.items.packageExpired.label', 'Paket ist abgelaufen'),
description: translate('settings.notifications.items.packageExpired.description', 'Benachrichtige mich, wenn das Paket abgelaufen ist.'),
},
{
key: 'credits_low',
label: translate('settings.notifications.items.creditsLow.label', 'Event-Credits werden knapp'),
description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Credit-Schwellen.'),
},
];
return map as Array<{ key: keyof NotificationPreferences; label: string; description: string }>;
}
function formatDateTime(value: string, locale: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
try {
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
} catch {
return date.toISOString();
}
}