mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 07:59:41 +02:00
feat(manacore): add QR code export to my-data page
- Add @manacore/qr-export dependency to web app - Create qr-export.ts service to collect contacts, events, todos - Create QRExportModal.svelte with QR preview and download buttons - Add QR-Code button to my-data settings page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c999999456
commit
d9c5554189
5 changed files with 2099 additions and 1083 deletions
|
|
@ -42,6 +42,7 @@
|
|||
"vitest": "^4.0.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/qr-export": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
190
apps/manacore/apps/web/src/lib/api/services/qr-export.ts
Normal file
190
apps/manacore/apps/web/src/lib/api/services/qr-export.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* QR Export API Service
|
||||
*
|
||||
* Collects data from contacts, calendar, and todo services for QR export.
|
||||
*/
|
||||
|
||||
import { contactsService, type Contact } from './contacts';
|
||||
import { calendarService, type CalendarEvent } from './calendar';
|
||||
import { todoService, type Task } from './todo';
|
||||
import type { UserDataSummary } from './my-data';
|
||||
import {
|
||||
createManaQRExport,
|
||||
type EncodeResult,
|
||||
type ContactInput,
|
||||
type EventInput,
|
||||
type TodoInput,
|
||||
type ContactRelation,
|
||||
type TodoPriority,
|
||||
} from '@manacore/qr-export';
|
||||
|
||||
/**
|
||||
* Data collected for QR export
|
||||
*/
|
||||
export interface QRExportData {
|
||||
contacts: Contact[];
|
||||
events: CalendarEvent[];
|
||||
tasks: Task[];
|
||||
userData: UserDataSummary | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of QR export generation
|
||||
*/
|
||||
export interface QRExportResult {
|
||||
encodeResult: EncodeResult;
|
||||
stats: {
|
||||
contactCount: number;
|
||||
eventCount: number;
|
||||
todoCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Contact to ContactInput for qr-export
|
||||
*/
|
||||
function mapContactToInput(contact: Contact): ContactInput {
|
||||
const displayName = contactsService.getDisplayName(contact);
|
||||
|
||||
// Determine relation based on available data
|
||||
// Default to 3 (Freund), but this could be enhanced with actual relation data
|
||||
let relation: ContactRelation = 3;
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
phone: contact.mobile || contact.phone,
|
||||
email: contact.email,
|
||||
relation,
|
||||
importance: contact.isFavorite ? 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CalendarEvent to EventInput for qr-export
|
||||
*/
|
||||
function mapEventToInput(event: CalendarEvent): EventInput {
|
||||
const startDate = new Date(event.startTime);
|
||||
const endDate = new Date(event.endTime);
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
location: event.location,
|
||||
allDay: event.isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Task to TodoInput for qr-export
|
||||
*/
|
||||
function mapTaskToInput(task: Task): TodoInput {
|
||||
// Map priority string to number
|
||||
const priorityMap: Record<string, TodoPriority> = {
|
||||
urgent: 1,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
const priority = priorityMap[task.priority] || 2;
|
||||
|
||||
return {
|
||||
title: task.title,
|
||||
priority,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : undefined,
|
||||
completed: task.isCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* QR Export service
|
||||
*/
|
||||
export const qrExportService = {
|
||||
/**
|
||||
* Collect all data needed for QR export
|
||||
*/
|
||||
async collectExportData(): Promise<QRExportData> {
|
||||
// Fetch all data in parallel
|
||||
const [contactsResult, eventsResult, tasksResult] = await Promise.all([
|
||||
contactsService.getFavoriteContacts(10), // Get more, we'll filter
|
||||
calendarService.getUpcomingEvents(30), // Next 30 days
|
||||
todoService.getUpcomingTasks(30), // Next 30 days
|
||||
]);
|
||||
|
||||
return {
|
||||
contacts: contactsResult.data || [],
|
||||
events: eventsResult.data || [],
|
||||
tasks: tasksResult.data || [],
|
||||
userData: null, // Will be set by caller if needed
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate QR export from collected data
|
||||
*/
|
||||
generateExport(
|
||||
data: QRExportData,
|
||||
options?: {
|
||||
maxContacts?: number;
|
||||
maxEvents?: number;
|
||||
maxTodos?: number;
|
||||
}
|
||||
): QRExportResult {
|
||||
const maxContacts = options?.maxContacts ?? 5;
|
||||
const maxEvents = options?.maxEvents ?? 10;
|
||||
const maxTodos = options?.maxTodos ?? 15;
|
||||
|
||||
// Map to input formats
|
||||
const contactInputs = data.contacts.map(mapContactToInput);
|
||||
const eventInputs = data.events.map(mapEventToInput);
|
||||
const taskInputs = data.tasks.map(mapTaskToInput);
|
||||
|
||||
// Build export using the builder
|
||||
const builder = createManaQRExport();
|
||||
|
||||
// Set user context if available
|
||||
if (data.userData?.user) {
|
||||
builder.user({
|
||||
n: data.userData.user.name || data.userData.user.email.split('@')[0],
|
||||
l: 'de', // Could be derived from user settings
|
||||
z: 'Europe/Berlin', // Could be derived from user settings
|
||||
});
|
||||
} else {
|
||||
builder.userName('ManaCore User');
|
||||
}
|
||||
|
||||
// Add data using smart selectors
|
||||
builder.contactsFrom(contactInputs, maxContacts);
|
||||
builder.eventsFrom(eventInputs, maxEvents);
|
||||
builder.todosFrom(taskInputs, maxTodos);
|
||||
|
||||
// Encode
|
||||
const encodeResult = builder.encode();
|
||||
|
||||
return {
|
||||
encodeResult,
|
||||
stats: {
|
||||
contactCount: Math.min(contactInputs.length, maxContacts),
|
||||
eventCount: Math.min(eventInputs.length, maxEvents),
|
||||
todoCount: Math.min(taskInputs.filter((t) => !t.completed).length, maxTodos),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate QR export with all data fetched automatically
|
||||
*/
|
||||
async generateFullExport(
|
||||
userData?: UserDataSummary | null,
|
||||
options?: {
|
||||
maxContacts?: number;
|
||||
maxEvents?: number;
|
||||
maxTodos?: number;
|
||||
}
|
||||
): Promise<QRExportResult> {
|
||||
const data = await this.collectExportData();
|
||||
data.userData = userData || null;
|
||||
return this.generateExport(data, options);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { ManaQRCode } from '@manacore/qr-export/svelte';
|
||||
import { toDataURL, toSVG } from '@manacore/qr-export/generate';
|
||||
import { qrExportService, type QRExportResult } from '$lib/api/services/qr-export';
|
||||
import type { UserDataSummary } from '$lib/api/services/my-data';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
userData: UserDataSummary | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { show, userData, onClose }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let exportResult = $state<QRExportResult | null>(null);
|
||||
|
||||
// Load export data when modal opens
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
loadExportData();
|
||||
} else {
|
||||
// Reset state when closing
|
||||
loading = true;
|
||||
error = null;
|
||||
exportResult = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadExportData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
exportResult = await qrExportService.generateFullExport(userData);
|
||||
} catch (e) {
|
||||
console.error('QR Export failed:', e);
|
||||
error = e instanceof Error ? e.message : 'Export fehlgeschlagen';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPNG() {
|
||||
if (!exportResult) return;
|
||||
|
||||
try {
|
||||
const dataUrl = await toDataURL(exportResult.encodeResult, { size: 600 });
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `mana-qr-export-${new Date().toISOString().split('T')[0]}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
console.error('PNG download failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSVG() {
|
||||
if (!exportResult) return;
|
||||
|
||||
try {
|
||||
const svgString = await toSVG(exportResult.encodeResult, { size: 600 });
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `mana-qr-export-${new Date().toISOString().split('T')[0]}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('SVG download failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
class="bg-card rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">QR-Code Export</h3>
|
||||
<p class="text-sm text-muted-foreground">Deine wichtigsten Daten als QR-Code</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading State -->
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div class="relative">
|
||||
<div class="w-16 h-16 border-4 border-primary/20 rounded-full animate-pulse"></div>
|
||||
<div
|
||||
class="absolute inset-0 w-16 h-16 border-4 border-transparent border-t-primary rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-muted-foreground">Daten werden geladen...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<!-- Error State -->
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4 text-center"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 text-red-500 mx-auto mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onclick={loadExportData}
|
||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else if exportResult}
|
||||
<!-- Success State -->
|
||||
<div class="space-y-6">
|
||||
<!-- QR Code Preview -->
|
||||
<div class="flex justify-center">
|
||||
<div class="p-4 bg-white rounded-lg shadow-inner">
|
||||
<ManaQRCode data={exportResult.encodeResult} size={256} svg={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Size Info -->
|
||||
<div class="rounded-lg border bg-muted/50 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium">Datengrosse</span>
|
||||
<span
|
||||
class="text-sm font-mono {exportResult.encodeResult.fitsInQR
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'}"
|
||||
>
|
||||
{formatBytes(exportResult.encodeResult.size)}
|
||||
{#if exportResult.encodeResult.fitsInQR}
|
||||
<span class="ml-1">✓</span>
|
||||
{:else}
|
||||
<span class="ml-1">✗</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div class="p-2 rounded bg-card">
|
||||
<p class="text-lg font-semibold">{exportResult.stats.contactCount}</p>
|
||||
<p class="text-xs text-muted-foreground">Kontakte</p>
|
||||
</div>
|
||||
<div class="p-2 rounded bg-card">
|
||||
<p class="text-lg font-semibold">{exportResult.stats.eventCount}</p>
|
||||
<p class="text-xs text-muted-foreground">Termine</p>
|
||||
</div>
|
||||
<div class="p-2 rounded bg-card">
|
||||
<p class="text-lg font-semibold">{exportResult.stats.todoCount}</p>
|
||||
<p class="text-xs text-muted-foreground">Todos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Text -->
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
Scanne diesen QR-Code mit einem kompatiblen Gerat, um deine wichtigsten Daten zu
|
||||
ubertragen. Der Code enthalt Kontakte, anstehende Termine und offene Aufgaben.
|
||||
</p>
|
||||
|
||||
<!-- Download Buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={downloadPNG}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
PNG
|
||||
</button>
|
||||
<button
|
||||
onclick={downloadSVG}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="mt-6 w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import StatCard from '$lib/components/admin/StatCard.svelte';
|
||||
import ProjectDataCard from '$lib/components/admin/ProjectDataCard.svelte';
|
||||
import DeleteConfirmationModal from '$lib/components/my-data/DeleteConfirmationModal.svelte';
|
||||
import QRExportModal from '$lib/components/my-data/QRExportModal.svelte';
|
||||
import { myDataService, type UserDataSummary } from '$lib/api/services/my-data';
|
||||
import type { DeleteUserDataResponse } from '$lib/api/services/admin';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -20,6 +21,9 @@
|
|||
let deleteResult = $state<DeleteUserDataResponse | null>(null);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
// QR Export dialog state
|
||||
let showQRDialog = $state(false);
|
||||
|
||||
async function loadMyData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
|
@ -119,33 +123,56 @@
|
|||
</div>
|
||||
</div>
|
||||
{#if userData}
|
||||
<button
|
||||
onclick={handleExport}
|
||||
disabled={exporting}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if exporting}
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (showQRDialog = true)}
|
||||
class="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
|
||||
title="Als QR-Code exportieren"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{exporting ? 'Exportiere...' : 'Daten exportieren'}</span>
|
||||
</button>
|
||||
<span>QR-Code</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleExport}
|
||||
disabled={exporting}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if exporting}
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{exporting ? 'Exportiere...' : 'Daten exportieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -465,3 +492,6 @@
|
|||
onConfirm={handleDelete}
|
||||
onClose={handleDeleteModalClose}
|
||||
/>
|
||||
|
||||
<!-- QR Export Modal -->
|
||||
<QRExportModal show={showQRDialog} {userData} onClose={() => (showQRDialog = false)} />
|
||||
|
|
|
|||
2669
pnpm-lock.yaml
generated
2669
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue