BillTracker/client/components/data/ImportMyDataSection.jsx

219 lines
9.9 KiB
JavaScript

import React, { useState, useRef } from 'react';
import { toast } from 'sonner';
import { Database, Upload, AlertTriangle, Loader2 } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared';
export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }) {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
const [confirmOpen, setConfirmOpen] = useState(false);
const reset = () => {
setFile(null);
setPreview({ status: 'idle', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
if (fileRef.current) fileRef.current.value = '';
};
const handlePreview = async () => {
if (!file) {
toast.error('Choose a SQLite data export first.');
return;
}
setPreview({ status: 'loading', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
try {
const data = await api.previewUserDbImport(file);
setPreview({ status: 'ready', data, error: null });
toast.success('SQLite export preview ready.');
} catch (err) {
setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') });
toast.error(err.message || 'SQLite import preview failed.');
}
};
const handleApply = () => {
if (!preview.data?.import_session_id) return;
setConfirmOpen(true);
};
const handleConfirmImport = async () => {
setConfirmOpen(false);
setApplyState({ status: 'loading', result: null, error: null });
try {
const result = await api.applyUserDbImport({
import_session_id: preview.data.import_session_id,
options: { overwrite: false },
});
setApplyState({ status: 'done', result, error: null });
toast.success('SQLite data import applied.');
onHistoryRefresh?.();
} catch (err) {
setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') });
toast.error(err.message || 'SQLite import apply failed.');
}
};
const counts = preview.data?.counts || {};
const summary = preview.data?.summary || {};
return (
<>
<SectionCard title="Import My Data Export"
subtitle="Restore data from a SQLite export created by this app for your account."
{...cardProps}>
<div className="px-6 py-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3">
<Database className="mt-0.5 h-5 w-5 text-primary shrink-0" />
<div>
<p className="text-sm font-medium">Import a SQLite data export created by this app.</p>
<p className="mt-1 text-xs text-muted-foreground">
This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported.
</p>
</div>
</div>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">SQLite export file</span>
<Input
ref={fileRef}
type="file"
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
onChange={e => {
setFile(e.target.files?.[0] || null);
setPreview({ status: 'idle', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
}}
/>
</label>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={reset}>
Clear
</Button>
<Button size="sm" type="button" disabled={!file || preview.status === 'loading'} onClick={handlePreview}>
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Previewing</>
: <><Upload className="h-3.5 w-3.5 mr-1.5" />Preview</>}
</Button>
</div>
</div>
{preview.status === 'error' && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />
{preview.error?.message || 'SQLite import preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.message || d.table || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
{preview.status === 'ready' && preview.data && (
<div className="mt-4 space-y-4">
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Preview ready</p>
<p className="mt-1 text-xs text-muted-foreground">
Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'}
</p>
</div>
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
User data only
</span>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<CountPill label="Bills" value={counts.bills} />
<CountPill label="Categories" value={counts.categories} />
<CountPill label="Payments" value={counts.payments} />
<CountPill label="Monthly" value={counts.monthly_bill_state} />
<CountPill label="Notes" value={counts.notes} />
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => (
<div key={key} className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs">
<p className="font-semibold capitalize">{key.replace(/_/g, ' ')}</p>
<p className="mt-1 text-muted-foreground">
create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0}
</p>
</div>
))}
</div>
{preview.data.warnings?.length > 0 && (
<div className="mt-4 space-y-1">
{preview.data.warnings.map((warning, i) => (
<p key={i} className="text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning}
</p>
))}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Review the preview before applying. Nothing is imported until you confirm.</p>
<Button size="sm" type="button" disabled={applyState.status === 'loading'} onClick={handleApply}>
{applyState.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Applying</>
: 'Apply Import'}
</Button>
</div>
</div>
)}
{applyState.status === 'done' && applyState.result && (
<div className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<p className="text-sm font-medium text-emerald-600">SQLite import applied</p>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<CountPill label="Created" value={applyState.result.rows_created} />
<CountPill label="Skipped" value={applyState.result.rows_skipped} />
<CountPill label="Conflicts" value={applyState.result.rows_conflicted} />
<CountPill label="Errors" value={applyState.result.rows_errored} />
</div>
</div>
)}
{applyState.status === 'error' && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || 'SQLite import apply failed.'}
</div>
)}
</div>
</SectionCard>
{/* Import confirmation dialog */}
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Import SQLite data export?</AlertDialogTitle>
<AlertDialogDescription>
Import this SQLite data export into your account? Existing records will be skipped by default.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmImport}>
Confirm Import
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}