refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
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';
|
|
|
|
|
|
2026-05-30 21:52:02 -05:00
|
|
|
export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }) {
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
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"
|
2026-05-30 21:52:02 -05:00
|
|
|
subtitle="Restore data from a SQLite export created by this app for your account."
|
|
|
|
|
{...cardProps}>
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
<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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|