Files
fotospiel-app/resources/js/admin/mobile/DataExportsPage.tsx
Codex Agent a3538f6470
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix data exports UI and scope format
2026-01-06 12:59:38 +01:00

302 lines
11 KiB
TypeScript

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 { 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<DataExportSummary['status'], 'success' | 'warning' | 'muted'> = {
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<string, string>)[i18n.language] ?? name.de ?? name.en ?? event.slug ?? '';
}
return event.slug ?? '';
}
export default function MobileDataExportsPage() {
const { t } = useTranslation('management');
const { textStrong, text, muted, danger } = useAdminTheme();
const [exports, setExports] = React.useState<DataExportSummary[]>([]);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [scope, setScope] = React.useState<'tenant' | 'event'>('tenant');
const [eventId, setEventId] = React.useState<number | 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<string | null>(null);
const back = useBackNavigation(adminPath('/mobile/profile'));
const load = React.useCallback(async () => {
setLoading(true);
try {
const [exportRows, eventRows] = await Promise.all([
listTenantDataExports(),
getEvents({ force: true }),
]);
setExports(exportRows);
setEvents(eventRows);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('dataExports.errors.load', 'Exports konnten nicht geladen werden.')));
} finally {
setLoading(false);
}
}, [t]);
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;
}
if (scope === 'event' && !eventId) {
setError(t('dataExports.errors.eventRequired', 'Bitte wähle ein Event aus.'));
return;
}
setRequesting(true);
try {
await requestTenantDataExport({
scope,
eventId: scope === 'event' ? eventId ?? 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);
}
};
return (
<MobileShell
activeTab="profile"
title={t('dataExports.title', 'Data exports')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton label={t('dataExports.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
<MobileCard space="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('dataExports.request.title', 'Export request')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.request.hint', 'Export tenant data or a specific event archive.')}
</Text>
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.scope', 'Scope')}
</Text>
<MobileSelect
value={scope}
onChange={(event) => setScope(event.target.value as 'tenant' | 'event')}
compact
style={{ minWidth: 140, maxWidth: 180 }}
>
<option value="tenant">{t('dataExports.scopes.tenant', 'Tenant')}</option>
<option value="event">{t('dataExports.scopes.event', 'Event')}</option>
</MobileSelect>
</XStack>
{scope === 'event' ? (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.event', 'Event')}
</Text>
<MobileSelect
value={eventId ? String(eventId) : ''}
onChange={(event) => setEventId(event.target.value ? Number(event.target.value) : null)}
compact
style={{ minWidth: 180, maxWidth: 220 }}
>
<option value="">{t('dataExports.fields.eventPlaceholder', 'Choose event')}</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{formatEventName(event) || event.slug}
</option>
))}
</MobileSelect>
</XStack>
) : null}
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.includeMedia', 'Include raw media')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.fields.includeMediaHint', 'Bigger ZIP; choose when needed.')}
</Text>
</YStack>
<Switch
size="$3"
checked={includeMedia}
onCheckedChange={setIncludeMedia}
aria-label={t('dataExports.fields.includeMedia', 'Include raw media')}
>
<Switch.Thumb />
</Switch>
</XStack>
</YStack>
<CTAButton
label={requesting ? t('dataExports.actions.requesting', 'Requesting...') : t('dataExports.actions.request', 'Request export')}
onPress={handleRequest}
disabled={requesting}
/>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('dataExports.history.title', 'Recent exports')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.history.hint', 'Latest 10 exports for your tenant and events.')}
</Text>
{loading ? (
<YStack space="$2">
<SkeletonCard height={72} />
<SkeletonCard height={72} />
</YStack>
) : exports.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('dataExports.history.empty', 'No exports yet.')}
</Text>
) : (
<YStack space="$2">
{exports.map((entry) => (
<MobileCard key={entry.id} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{entry.scope === 'event'
? t('dataExports.scopes.event', 'Event export')
: t('dataExports.scopes.tenant', 'Tenant export')}
</Text>
{entry.event ? (
<Text fontSize="$xs" color={muted}>
{formatEventName(entry.event)}
</Text>
) : null}
</YStack>
<PillBadge tone={statusTone[entry.status]}>
{t(`dataExports.status.${entry.status}`, entry.status)}
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{formatRelativeTime(entry.created_at, { locale: i18n.language }) || '—'}
</Text>
<Text fontSize="$xs" color={muted}>
{formatBytes(entry.size_bytes)}
</Text>
</XStack>
{entry.include_media ? (
<PillBadge tone="muted">{t('dataExports.badges.includesMedia', 'Raw media')}</PillBadge>
) : null}
{entry.download_url ? (
<CTAButton
label={t('dataExports.actions.download', 'Download')}
onPress={async () => {
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' ? (
<Text fontSize="$xs" color={danger}>
{entry.error_message ?? t('dataExports.errors.failed', 'Export failed.')}
</Text>
) : null}
</MobileCard>
))}
</YStack>
)}
</MobileCard>
</MobileShell>
);
}