Implement compliance exports and retention overrides
This commit is contained in:
@@ -430,6 +430,23 @@ export type NotificationLogEntry = {
|
||||
is_read?: boolean;
|
||||
};
|
||||
|
||||
export type DataExportSummary = {
|
||||
id: number;
|
||||
scope: 'tenant' | 'event';
|
||||
status: 'pending' | 'processing' | 'ready' | 'failed';
|
||||
include_media: boolean;
|
||||
size_bytes: number | null;
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
download_url: string | null;
|
||||
error_message?: string | null;
|
||||
event?: {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string | Record<string, string>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PaddleTransactionSummary = {
|
||||
id: string | null;
|
||||
status: string | null;
|
||||
@@ -2133,6 +2150,39 @@ function normalizeNotificationLog(entry: JsonValue): NotificationLogEntry | null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDataExport(entry: JsonValue): DataExportSummary | null {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = entry as Record<string, JsonValue>;
|
||||
const event = row.event;
|
||||
const eventRecord = event && typeof event === 'object' && !Array.isArray(event)
|
||||
? (event as Record<string, JsonValue>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
scope: row.scope === 'event' ? 'event' : 'tenant',
|
||||
status: typeof row.status === 'string' ? (row.status as DataExportSummary['status']) : 'pending',
|
||||
include_media: Boolean(row.include_media),
|
||||
size_bytes: typeof row.size_bytes === 'number' ? row.size_bytes : null,
|
||||
created_at: typeof row.created_at === 'string' ? row.created_at : null,
|
||||
expires_at: typeof row.expires_at === 'string' ? row.expires_at : null,
|
||||
download_url: typeof row.download_url === 'string' ? row.download_url : null,
|
||||
error_message: typeof row.error_message === 'string' ? row.error_message : null,
|
||||
event: eventRecord
|
||||
? {
|
||||
id: Number(eventRecord.id ?? 0),
|
||||
slug: typeof eventRecord.slug === 'string' ? eventRecord.slug : '',
|
||||
name: typeof eventRecord.name === 'string' || typeof eventRecord.name === 'object'
|
||||
? (eventRecord.name as DataExportSummary['event']['name'])
|
||||
: '',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNotificationLogs(options?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@@ -2178,6 +2228,51 @@ export async function markNotificationLogs(ids: number[], status: 'read' | 'dism
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTenantDataExports(): Promise<DataExportSummary[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/exports');
|
||||
const payload = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load data exports');
|
||||
const rows = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
return rows
|
||||
.map((row) => normalizeDataExport(row))
|
||||
.filter((row): row is DataExportSummary => Boolean(row));
|
||||
}
|
||||
|
||||
export async function requestTenantDataExport(payload: {
|
||||
scope: 'tenant' | 'event';
|
||||
eventId?: number;
|
||||
includeMedia?: boolean;
|
||||
}): Promise<DataExportSummary | null> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/exports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: payload.scope,
|
||||
event_id: payload.eventId,
|
||||
include_media: payload.includeMedia,
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to request export');
|
||||
const record = body.data ? normalizeDataExport(body.data) : null;
|
||||
|
||||
return record ?? null;
|
||||
}
|
||||
|
||||
export async function downloadTenantDataExport(downloadUrl: string): Promise<Blob> {
|
||||
const response = await authorizedFetch(downloadUrl, {
|
||||
headers: { 'Accept': 'application/octet-stream' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to download data export', response.status, payload);
|
||||
throw new Error('Failed to download data export');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
data: PaddleTransactionSummary[];
|
||||
nextCursor: string | null;
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
|
||||
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
|
||||
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
|
||||
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
|
||||
export const ADMIN_DATA_EXPORTS_PATH = adminPath('/mobile/exports');
|
||||
export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads');
|
||||
export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');
|
||||
export const ADMIN_WELCOME_BASE_PATH = adminPath('/mobile/welcome');
|
||||
|
||||
@@ -2623,5 +2623,52 @@
|
||||
"send": "Benachrichtigung senden",
|
||||
"validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu."
|
||||
}
|
||||
},
|
||||
"dataExports": {
|
||||
"title": "Datenexporte",
|
||||
"request": {
|
||||
"title": "Exportanfrage",
|
||||
"hint": "Exportiere Mandantendaten oder ein einzelnes Event-Archiv."
|
||||
},
|
||||
"fields": {
|
||||
"scope": "Umfang",
|
||||
"event": "Veranstaltung",
|
||||
"eventPlaceholder": "Event auswählen",
|
||||
"includeMedia": "Originaldateien einschließen",
|
||||
"includeMediaHint": "Größeres ZIP; nur bei Bedarf."
|
||||
},
|
||||
"scopes": {
|
||||
"tenant": "Mandantenexport",
|
||||
"event": "Event-Export"
|
||||
},
|
||||
"history": {
|
||||
"title": "Letzte Exporte",
|
||||
"hint": "Die letzten 10 Exporte für Mandant und Events.",
|
||||
"empty": "Noch keine Exporte."
|
||||
},
|
||||
"status": {
|
||||
"pending": "Ausstehend",
|
||||
"processing": "In Arbeit",
|
||||
"ready": "Bereit",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"badges": {
|
||||
"includesMedia": "Originaldateien"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"request": "Export anfordern",
|
||||
"requesting": "Wird angefordert...",
|
||||
"requested": "Export wird vorbereitet.",
|
||||
"download": "Herunterladen",
|
||||
"downloaded": "Download gestartet."
|
||||
},
|
||||
"errors": {
|
||||
"load": "Exporte konnten nicht geladen werden.",
|
||||
"request": "Export konnte nicht gestartet werden.",
|
||||
"eventRequired": "Bitte zuerst ein Event auswählen.",
|
||||
"failed": "Export fehlgeschlagen.",
|
||||
"download": "Download fehlgeschlagen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2627,5 +2627,52 @@
|
||||
"send": "Send notification",
|
||||
"validation": "Add a title, message, and target guest when needed."
|
||||
}
|
||||
},
|
||||
"dataExports": {
|
||||
"title": "Data exports",
|
||||
"request": {
|
||||
"title": "Export request",
|
||||
"hint": "Export tenant data or a specific event archive."
|
||||
},
|
||||
"fields": {
|
||||
"scope": "Scope",
|
||||
"event": "Event",
|
||||
"eventPlaceholder": "Choose event",
|
||||
"includeMedia": "Include raw media",
|
||||
"includeMediaHint": "Bigger ZIP; choose when needed."
|
||||
},
|
||||
"scopes": {
|
||||
"tenant": "Tenant export",
|
||||
"event": "Event export"
|
||||
},
|
||||
"history": {
|
||||
"title": "Recent exports",
|
||||
"hint": "Latest 10 exports for your tenant and events.",
|
||||
"empty": "No exports yet."
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"processing": "Processing",
|
||||
"ready": "Ready",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"badges": {
|
||||
"includesMedia": "Raw media"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"request": "Request export",
|
||||
"requesting": "Requesting...",
|
||||
"requested": "Export is being prepared.",
|
||||
"download": "Download",
|
||||
"downloaded": "Download started."
|
||||
},
|
||||
"errors": {
|
||||
"load": "Exports could not be loaded.",
|
||||
"request": "Export could not be started.",
|
||||
"eventRequired": "Select an event first.",
|
||||
"failed": "Export failed.",
|
||||
"download": "Download failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
277
resources/js/admin/mobile/DataExportsPage.tsx
Normal file
277
resources/js/admin/mobile/DataExportsPage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
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';
|
||||
|
||||
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 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: 160 }}
|
||||
>
|
||||
<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: 200 }}
|
||||
>
|
||||
<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 checked={includeMedia} onCheckedChange={setIncludeMedia} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogOut, User, Settings, Shield, Globe, Moon } from 'lucide-react';
|
||||
import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
@@ -12,7 +12,7 @@ import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { MobileSelect } from './components/FormControls';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { fetchTenantProfile } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { adminPath, ADMIN_DATA_EXPORTS_PATH } from '../constants';
|
||||
import i18n from '../i18n';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
@@ -131,6 +131,22 @@ export default function MobileProfilePage() {
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('dataExports.title', 'Data exports')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Download size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<ListItem
|
||||
paddingVertical="$2"
|
||||
|
||||
@@ -36,6 +36,7 @@ const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsP
|
||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
||||
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
|
||||
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
|
||||
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
|
||||
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
|
||||
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
|
||||
@@ -203,6 +204,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/exports', element: <RequireAdminAccess><MobileDataExportsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
|
||||
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> },
|
||||
{ path: 'mobile/uploads', element: <MobileUploadsTabPage /> },
|
||||
|
||||
Reference in New Issue
Block a user