error handling
This commit is contained in:
parent
e8218a3dd8
commit
1426ee3bb5
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ChevronDown, Plus, Pencil, Trash2, ReceiptText,
|
ChevronDown, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api.js';
|
import { api } from '@/api.js';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
|
|
@ -231,6 +231,7 @@ function ExpandedBills({ category }) {
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(null);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(() => new Set());
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
|
|
@ -243,11 +244,12 @@ export default function CategoriesPage() {
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const cats = await api.categories();
|
const cats = await api.categories();
|
||||||
setCategories(cats);
|
setCategories(cats);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
setLoadError(err.message || 'Failed to load categories');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -412,6 +414,17 @@ export default function CategoriesPage() {
|
||||||
<div className="table-surface overflow-hidden rounded-xl">
|
<div className="table-surface overflow-hidden rounded-xl">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive mb-3" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Failed to load categories</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={load}
|
||||||
|
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : visibleCategories.length === 0 ? (
|
) : visibleCategories.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Sun, Moon, Users } from 'lucide-react';
|
import { Sun, Moon, Users, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -218,16 +218,21 @@ export default function SettingsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const [settings, setSettings] = useState(DEFAULTS);
|
const [settings, setSettings] = useState(DEFAULTS);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadSettings = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
api.settings()
|
api.settings()
|
||||||
.then((d) => setSettings({ ...DEFAULTS, ...d }))
|
.then((d) => setSettings({ ...DEFAULTS, ...d }))
|
||||||
.catch(() => {})
|
.catch((err) => setLoadError(err.message || 'Failed to load settings'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => { loadSettings(); }, [loadSettings]);
|
||||||
|
|
||||||
const set = (k, v) => setSettings((p) => ({ ...p, [k]: v }));
|
const set = (k, v) => setSettings((p) => ({ ...p, [k]: v }));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -254,6 +259,21 @@ export default function SettingsPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
||||||
|
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Failed to load settings</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={loadSettings}
|
||||||
|
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle } from 'lucide-react';
|
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -425,6 +425,7 @@ export default function SnowballPage() {
|
||||||
const [bills, setBills] = useState([]);
|
const [bills, setBills] = useState([]);
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [editBill, setEditBill] = useState(null);
|
const [editBill, setEditBill] = useState(null);
|
||||||
|
|
@ -454,6 +455,7 @@ export default function SnowballPage() {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const [billsArr, catsArr, settings] = await Promise.all([
|
const [billsArr, catsArr, settings] = await Promise.all([
|
||||||
api.snowball(), api.categories(), api.snowballSettings(),
|
api.snowball(), api.categories(), api.snowballSettings(),
|
||||||
|
|
@ -468,7 +470,7 @@ export default function SnowballPage() {
|
||||||
setExtraPayment(ep);
|
setExtraPayment(ep);
|
||||||
extraPaymentRef.current = ep;
|
extraPaymentRef.current = ep;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load snowball data');
|
setLoadError(err.message || 'Failed to load snowball data');
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -666,6 +668,21 @@ export default function SnowballPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
||||||
|
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Failed to load snowball data</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={load}
|
||||||
|
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
|
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api.js';
|
import { api } from '@/api.js';
|
||||||
import { useTracker } from '@/hooks/useQueries';
|
import { useTracker } from '@/hooks/useQueries';
|
||||||
|
|
@ -2170,15 +2170,6 @@ export default function TrackerPage() {
|
||||||
setMonth(n.getMonth() + 1);
|
setMonth(n.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle errors from React Query (use ref to prevent duplicate toasts)
|
|
||||||
const errorShownRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (isError && !errorShownRef.current) {
|
|
||||||
toast.error(error?.message || 'Failed to load tracker data');
|
|
||||||
errorShownRef.current = true;
|
|
||||||
}
|
|
||||||
if (!isError) errorShownRef.current = false;
|
|
||||||
}, [isError, error]);
|
|
||||||
|
|
||||||
const rows = data?.rows || [];
|
const rows = data?.rows || [];
|
||||||
const summary = data?.summary || {};
|
const summary = data?.summary || {};
|
||||||
|
|
@ -2379,8 +2370,28 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Fetch error state ── */}
|
||||||
|
{isError && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center mb-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Failed to load tracker data</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{error?.message || 'An unexpected error occurred.'}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Empty state ── */}
|
{/* ── Empty state ── */}
|
||||||
{rows.length === 0 && data !== null && (
|
{!isError && rows.length === 0 && data !== null && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
|
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
|
||||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||||
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
|
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
|
@ -2393,7 +2404,7 @@ export default function TrackerPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
|
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
|
||||||
{loading && (
|
{!isError && loading && (
|
||||||
<div className="space-y-5" aria-busy="true">
|
<div className="space-y-5" aria-busy="true">
|
||||||
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
|
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -2425,8 +2436,8 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
|
{!isError && first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
|
||||||
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
|
{!isError && second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
|
||||||
|
|
||||||
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
|
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
|
||||||
{editBillData && (
|
{editBillData && (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue