382 lines
14 KiB
TypeScript
382 lines
14 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 { 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<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 ?? '';
|
|
}
|
|
|
|
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<DataExportSummary[]>([]);
|
|
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
|
const isRecap = variant === 'recap';
|
|
const [scope, setScope] = React.useState<'tenant' | 'event'>(isRecap ? 'event' : 'tenant');
|
|
const [eventId, setEventId] = React.useState<number | null>(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<string | null>(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 ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{error}
|
|
</Text>
|
|
<CTAButton label={t('dataExports.actions.refresh', 'Refresh')} tone="ghost" onPress={refresh} />
|
|
</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">
|
|
{!isRecap ? (
|
|
<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>
|
|
) : (
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$sm" color={text}>
|
|
{t('dataExports.fields.event', 'Event')}
|
|
</Text>
|
|
<Text fontSize="$sm" color={textStrong}>
|
|
{formatEventName(event) || event?.slug || t('dataExports.fields.eventPlaceholder', 'Choose event')}
|
|
</Text>
|
|
</XStack>
|
|
)}
|
|
{!isRecap && 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>
|
|
{hasInProgress ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('dataExports.request.progress', 'Export is running. This list refreshes automatically.')}
|
|
</Text>
|
|
) : null}
|
|
</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>
|
|
) : visibleExports.length === 0 ? (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('dataExports.history.empty', 'No exports yet.')}
|
|
</Text>
|
|
) : (
|
|
<YStack space="$2">
|
|
{visibleExports.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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function MobileDataExportsPage() {
|
|
const { t } = useTranslation('management');
|
|
const { textStrong } = useAdminTheme();
|
|
const back = useBackNavigation(adminPath('/mobile/profile'));
|
|
const [refresh, setRefresh] = React.useState<null | (() => void)>(null);
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="profile"
|
|
title={t('dataExports.title', 'Data exports')}
|
|
onBack={back}
|
|
headerActions={
|
|
<HeaderActionButton onPress={() => refresh?.()} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
<RefreshCcw size={18} color={textStrong} />
|
|
</HeaderActionButton>
|
|
}
|
|
>
|
|
<XStack justifyContent="flex-end">
|
|
<ContextHelpLink slug="billing-packages-exports" />
|
|
</XStack>
|
|
<DataExportsPanel onRefreshReady={setRefresh} />
|
|
</MobileShell>
|
|
);
|
|
}
|