import React from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshCcw } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Switch } from '@tamagui/switch'; import toast from 'react-hot-toast'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { ContextHelpLink } from './components/ContextHelpLink'; import { MobileSelect } from './components/FormControls'; import { DataExportSummary, downloadTenantDataExport, getEvents, listTenantDataExports, requestTenantDataExport, TenantEvent, } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { adminPath } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { formatRelativeTime } from './lib/relativeTime'; import { useAdminTheme } from './theme'; import i18n from '../i18n'; import { triggerDownloadFromBlob } from './invite-layout/export-utils'; import { hasInProgressExports } from './lib/dataExports'; const statusTone: Record = { pending: 'warning', processing: 'warning', ready: 'success', failed: 'muted', }; function formatBytes(bytes: number | null): string { if (!bytes || bytes <= 0) { return '—'; } const units = ['B', 'KB', 'MB', 'GB', 'TB']; const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); const value = bytes / Math.pow(1024, index); return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; } function formatEventName(event: TenantEvent | DataExportSummary['event'] | null): string { if (!event) { return ''; } const name = event.name; if (typeof name === 'string') { return name; } if (name && typeof name === 'object') { return (name as Record)[i18n.language] ?? name.de ?? name.en ?? event.slug ?? ''; } return event.slug ?? ''; } type DataExportsPanelProps = { variant?: 'page' | 'recap'; event?: TenantEvent | null; onRefreshReady?: (refresh: () => void) => void; }; export function DataExportsPanel({ variant = 'page', event, onRefreshReady, }: DataExportsPanelProps) { const { t } = useTranslation('management'); const { textStrong, text, muted, danger } = useAdminTheme(); const [exports, setExports] = React.useState([]); const [events, setEvents] = React.useState([]); const isRecap = variant === 'recap'; const [scope, setScope] = React.useState<'tenant' | 'event'>(isRecap ? 'event' : 'tenant'); const [eventId, setEventId] = React.useState(isRecap ? event?.id ?? null : null); const [includeMedia, setIncludeMedia] = React.useState(false); const [loading, setLoading] = React.useState(true); const [requesting, setRequesting] = React.useState(false); const [error, setError] = React.useState(null); React.useEffect(() => { if (!isRecap) { return; } if (event?.id && eventId !== event.id) { setEventId(event.id); } if (event) { setEvents([event]); } }, [event, eventId, isRecap]); const load = React.useCallback(async () => { setLoading(true); try { const [exportRows, eventRows] = await Promise.all([ listTenantDataExports(), isRecap ? Promise.resolve(event ? [event] : []) : getEvents({ force: true }), ]); setExports(exportRows); if (!isRecap) { setEvents(eventRows); } else if (eventRows.length > 0) { setEvents(eventRows); } setError(null); } catch (err) { setError(getApiErrorMessage(err, t('dataExports.errors.load', 'Exports konnten nicht geladen werden.'))); } finally { setLoading(false); } }, [event, isRecap, t]); const refresh = React.useCallback(() => { void load(); }, [load]); React.useEffect(() => { onRefreshReady?.(refresh); }, [onRefreshReady, refresh]); React.useEffect(() => { void load(); }, [load]); const hasInProgress = hasInProgressExports(exports); React.useEffect(() => { if (!hasInProgress) { return; } const interval = window.setInterval(() => { void load(); }, 5000); return () => { window.clearInterval(interval); }; }, [hasInProgress, load]); const handleRequest = async () => { if (requesting) { return; } const requestScope = isRecap ? 'event' : scope; const requestEventId = isRecap ? eventId : eventId; if (requestScope === 'event' && !requestEventId) { setError(t('dataExports.errors.eventRequired', 'Bitte wähle ein Event aus.')); return; } setRequesting(true); try { await requestTenantDataExport({ scope: requestScope, eventId: requestScope === 'event' ? requestEventId ?? undefined : undefined, includeMedia, }); toast.success(t('dataExports.actions.requested', 'Export wird vorbereitet.')); await load(); setError(null); } catch (err) { setError(getApiErrorMessage(err, t('dataExports.errors.request', 'Export konnte nicht gestartet werden.'))); } finally { setRequesting(false); } }; const visibleExports = React.useMemo(() => { if (!isRecap) { return exports; } if (!event?.id) { return exports.filter((entry) => entry.scope === 'event'); } return exports.filter((entry) => entry.scope === 'event' && entry.event?.id === event.id); }, [event?.id, exports, isRecap]); return ( <> {error ? ( {error} ) : null} {t('dataExports.request.title', 'Export request')} {t('dataExports.request.hint', 'Export tenant data or a specific event archive.')} {!isRecap ? ( {t('dataExports.fields.scope', 'Scope')} setScope(event.target.value as 'tenant' | 'event')} compact style={{ minWidth: 140, maxWidth: 180 }} > ) : ( {t('dataExports.fields.event', 'Event')} {formatEventName(event) || event?.slug || t('dataExports.fields.eventPlaceholder', 'Choose event')} )} {!isRecap && scope === 'event' ? ( {t('dataExports.fields.event', 'Event')} setEventId(event.target.value ? Number(event.target.value) : null)} compact style={{ minWidth: 180, maxWidth: 220 }} > {events.map((event) => ( ))} ) : null} {t('dataExports.fields.includeMedia', 'Include raw media')} {t('dataExports.fields.includeMediaHint', 'Bigger ZIP; choose when needed.')} {hasInProgress ? ( {t('dataExports.request.progress', 'Export is running. This list refreshes automatically.')} ) : null} {t('dataExports.history.title', 'Recent exports')} {t('dataExports.history.hint', 'Latest 10 exports for your tenant and events.')} {loading ? ( ) : visibleExports.length === 0 ? ( {t('dataExports.history.empty', 'No exports yet.')} ) : ( {visibleExports.map((entry) => ( {entry.scope === 'event' ? t('dataExports.scopes.event', 'Event export') : t('dataExports.scopes.tenant', 'Tenant export')} {entry.event ? ( {formatEventName(entry.event)} ) : null} {t(`dataExports.status.${entry.status}`, entry.status)} {formatRelativeTime(entry.created_at, { locale: i18n.language }) || '—'} {formatBytes(entry.size_bytes)} {entry.include_media ? ( {t('dataExports.badges.includesMedia', 'Raw media')} ) : null} {entry.download_url ? ( { if (!entry.download_url) { return; } try { const blob = await downloadTenantDataExport(entry.download_url); const filename = `fotospiel-data-export-${entry.id}.zip`; triggerDownloadFromBlob(blob, filename); toast.success(t('dataExports.actions.downloaded', 'Download started.')); } catch (err) { toast.error(getApiErrorMessage(err, t('dataExports.errors.download', 'Download failed.'))); } }} /> ) : entry.status === 'failed' ? ( {entry.error_message ?? t('dataExports.errors.failed', 'Export failed.')} ) : null} ))} )} ); } export default function MobileDataExportsPage() { const { t } = useTranslation('management'); const { textStrong } = useAdminTheme(); const back = useBackNavigation(adminPath('/mobile/profile')); const [refresh, setRefresh] = React.useState void)>(null); return ( refresh?.()} ariaLabel={t('common.refresh', 'Refresh')}> } > ); }