events werden nun erfolgreich gespeichert, branding wird nun erfolgreich gespeichert, emotionen können nun angelegt werden. Task Ansicht im Event admin verbessert, Buttons in FAB umgewandelt und vereinheitlicht. Teilen-Link Guest PWA schicker gemacht, SynGoogleFonts ausgebaut (mit Einzel-Family-Download).

This commit is contained in:
Codex Agent
2025-11-27 16:08:08 +01:00
parent bfa15cc48e
commit 96f8c5d63c
39 changed files with 1970 additions and 640 deletions

View File

@@ -15,8 +15,16 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { AdminLayout } from '../components/AdminLayout';
import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api';
import {
getEmotions,
createEmotion,
updateEmotion,
deleteEmotion,
TenantEmotion,
EmotionPayload,
} from '../api';
import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
type EmotionFormState = {
name: string;
@@ -49,6 +57,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<TenantEmotion | null>(null);
const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
@@ -107,9 +116,11 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const created = await createEmotion(payload);
setEmotions((prev) => [created, ...prev]);
setDialogOpen(false);
toast.success(t('emotions.toast.created', 'Emotion erstellt.'));
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.create'));
toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.'));
}
} finally {
setSaving(false);
@@ -120,13 +131,35 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
try {
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
toast.success(
updated.is_active
? t('emotions.toast.activated', 'Emotion aktiviert.')
: t('emotions.toast.deactivated', 'Emotion deaktiviert.')
);
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.toggle'));
toast.error(t('emotions.toast.errorToggle', 'Emotion konnte nicht aktualisiert werden.'));
}
}
}
async function handleDeleteEmotion(emotion: TenantEmotion) {
setSaving(true);
try {
await deleteEmotion(emotion.id);
setEmotions((prev) => prev.filter((item) => item.id !== emotion.id));
toast.success(t('emotions.toast.deleted', 'Emotion gelöscht.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('emotions.toast.deleteError', 'Emotion konnte nicht gelöscht werden.'));
}
} finally {
setSaving(false);
setDeleteTarget(null);
}
}
const locale = i18n.language.startsWith('en') ? enGB : de;
const title = embedded ? t('emotions.title') : t('emotions.title');
const subtitle = embedded
@@ -165,17 +198,18 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
) : emotions.length === 0 ? (
<EmptyEmotionsState onCreate={openCreateDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
<EmotionCard
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
locale={locale}
/>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
<EmotionCard
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
onDelete={() => setDeleteTarget(emotion)}
locale={locale}
/>
))}
</div>
)}
</CardContent>
</Card>
@@ -187,6 +221,29 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
saving={saving}
onSubmit={handleCreate}
/>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('emotions.delete.title', 'Emotion löschen?')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-slate-600">
{t('emotions.delete.confirm', { defaultValue: 'Soll "{{name}}" wirklich gelöscht werden?' , name: deleteTarget?.name ?? '' })}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t('actions.cancel', 'Abbrechen')}
</Button>
<Button
variant="destructive"
onClick={() => deleteTarget && void handleDeleteEmotion(deleteTarget)}
disabled={saving}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.delete', 'Löschen')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -203,10 +260,12 @@ export default function EmotionsPage() {
function EmotionCard({
emotion,
onToggle,
onDelete,
locale,
}: {
emotion: TenantEmotion;
onToggle: () => void;
onDelete: () => void;
locale: Locale;
}) {
const { t } = useTranslation('management');
@@ -252,7 +311,13 @@ function EmotionCard({
<Power className="mr-1 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button>
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
{!emotion.is_global ? (
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50" onClick={onDelete}>
{t('actions.delete', 'Löschen')}
</Button>
) : (
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
)}
</CardFooter>
</Card>
);