diff --git a/client/App.jsx b/client/App.jsx index 4b93235..7c741a6 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -40,6 +40,7 @@ const RoadmapPage = lazy(() => import('@/pages/RoadmapPage')); const DataPage = lazy(() => import('@/pages/DataPage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage')); const SnowballPage = lazy(() => import('@/pages/SnowballPage')); +const HealthPage = lazy(() => import('@/pages/HealthPage')); function RequireAuth({ children, role }) { const { user, singleUserMode } = useAuth(); @@ -186,6 +187,7 @@ export default function App() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/client/api.js b/client/api.js index bf077f2..676b9d9 100644 --- a/client/api.js +++ b/client/api.js @@ -141,6 +141,7 @@ export const api = { // Bills bills: () => get('/bills'), allBills: () => get('/bills?inactive=true'), + billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), updateBill: (id, data) => put(`/bills/${id}`, data), diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index e51debf..1893d2c 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { - Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, + Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, Settings, ShieldCheck, Tag, TrendingDown, User, X, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -35,6 +35,7 @@ const trackerItems = [ { to: '/summary', icon: ClipboardList, label: 'Summary' }, { to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/categories', icon: Tag, label: 'Categories' }, + { to: '/health', icon: ClipboardCheck, label: 'Health' }, { to: '/snowball', icon: TrendingDown, label: 'Snowball' }, ]; diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index 2f15c8b..42e0536 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -23,7 +23,7 @@ import AppNavigation from '@/components/layout/Sidebar'; // ─── Helpers ────────────────────────────────────────────────────────────────── -const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png'; +const AUTHENTIK_ICON_URL = '/img/auth.png'; function SectionHeading({ children }) { return

{children}

; @@ -739,12 +739,6 @@ function AuthMethodsCard() { {/* OIDC / authentik login toggle */}
- set('oidc_login_enabled', v)} diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index 3e48ccf..9b2ba26 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.jsx @@ -28,6 +28,10 @@ function billPreview(names = []) { return `${visible}${more}`; } +function categoryBillCount(category) { + return (category.active_bill_count || 0) + (category.inactive_bill_count || 0); +} + function Chip({ value, label, tone = 'muted', details }) { const toneClass = { active: 'border-primary/25 bg-primary/10 text-primary', @@ -230,6 +234,7 @@ export default function CategoriesPage() { const [newName, setNewName] = useState(''); const [adding, setAdding] = useState(false); const [expanded, setExpanded] = useState(() => new Set()); + const [showEmptyCategories, setShowEmptyCategories] = useState(false); const addInputRef = useRef(null); const [renameTarget, setRenameTarget] = useState(null); @@ -279,6 +284,7 @@ export default function CategoriesPage() { await api.createCategory({ name: trimmed }); toast.success(`"${trimmed}" added`); setNewName(''); + setShowEmptyCategories(true); addInputRef.current?.focus(); load(); } catch (err) { @@ -349,6 +355,10 @@ export default function CategoriesPage() { const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0); const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0); const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0); + const emptyCategories = categories.filter(cat => categoryBillCount(cat) === 0); + const visibleCategories = showEmptyCategories + ? categories + : categories.filter(cat => categoryBillCount(cat) > 0); return ( @@ -402,13 +412,45 @@ export default function CategoriesPage() {
{loading ? (
Loading...
- ) : categories.length === 0 ? ( -
- No categories yet. Add one above. + ) : visibleCategories.length === 0 ? ( +
+ + {categories.length === 0 + ? 'No categories yet. Add one above.' + : 'No categories with bills yet.'} + + {emptyCategories.length > 0 && ( + + )}
) : (
- {categories.map((cat) => { + {emptyCategories.length > 0 && ( +
+ + {showEmptyCategories + ? `Showing ${plural(emptyCategories.length, 'empty category')}.` + : `${plural(emptyCategories.length, 'empty category')} hidden until a bill uses them.`} + + +
+ )} + {visibleCategories.map((cat) => { const isExpanded = expanded.has(cat.id); const preview = billPreview(cat.bill_names); return ( diff --git a/client/pages/HealthPage.jsx b/client/pages/HealthPage.jsx new file mode 100644 index 0000000..e9ce80e --- /dev/null +++ b/client/pages/HealthPage.jsx @@ -0,0 +1,215 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { toast } from 'sonner'; +import { + AlertTriangle, + CheckCircle2, + ClipboardCheck, + Loader2, + RefreshCw, + Receipt, +} from 'lucide-react'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +const FIELD_LABELS = { + due_day: 'Due day', + category_id: 'Category', + minimum_payment: 'Minimum payment', + autopay_enabled: 'Autopay details', + interest_rate: 'APR', +}; + +function severityClass(severity) { + return severity === 'error' + ? 'border-destructive/25 bg-destructive/10 text-destructive' + : 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300'; +} + +function severityWeight(severity) { + return severity === 'error' ? 0 : 1; +} + +function issueSummary(issues = []) { + const errors = issues.filter(issue => issue.severity === 'error').length; + const warnings = issues.length - errors; + if (errors && warnings) return `${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`; + if (errors) return `${errors} error${errors === 1 ? '' : 's'}`; + return `${warnings} warning${warnings === 1 ? '' : 's'}`; +} + +function StatCard({ label, value, tone = 'default' }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function IssuePill({ issue }) { + return ( + + {issue.severity} + + ); +} + +function BillIssueCard({ bill }) { + const sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity)); + + return ( + + +
+
+ + + {bill.name} + + {issueSummary(bill.issues)} + + + + {bill.category_name || 'No category'} - {bill.due_day ? `Due day ${bill.due_day}` : 'No due day'} + {!bill.active && ' - Inactive'} + +
+ +
+
+ + {sortedIssues.map(issue => ( +
+
+ + + {FIELD_LABELS[issue.field] || issue.field} + +
+

{issue.suggestion}

+
+ ))} +
+
+ ); +} + +export default function HealthPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [includeInactive, setIncludeInactive] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + setData(await api.billAudit(includeInactive)); + } catch (err) { + toast.error(err.message || 'Could not run bill health check.'); + } finally { + setLoading(false); + } + }, [includeInactive]); + + useEffect(() => { load(); }, [load]); + + const summary = data?.summary || {}; + const bills = data?.bills || []; + const sortedBills = useMemo(() => [...bills].sort((a, b) => { + const aErrors = a.issues.filter(issue => issue.severity === 'error').length; + const bErrors = b.issues.filter(issue => issue.severity === 'error').length; + if (aErrors !== bErrors) return bErrors - aErrors; + if (a.issues.length !== b.issues.length) return b.issues.length - a.issues.length; + return a.name.localeCompare(b.name); + }), [bills]); + const hasIssues = sortedBills.length > 0; + const healthTone = useMemo(() => { + if ((summary.error_count || 0) > 0) return 'error'; + if ((summary.warning_count || 0) > 0) return 'warning'; + return 'ok'; + }, [summary.error_count, summary.warning_count]); + + return ( +
+
+
+
+
+ +
+
+

Bill Health

+

Find setup gaps before they skew tracker and snowball results.

+
+
+
+
+ + +
+
+ +
+ + + 0 ? 'error' : 'default'} /> + 0 ? 'warning' : 'default'} /> +
+ + {loading ? ( + + + + Running health check... + + + ) : !hasIssues ? ( + + +
+ +
+
+

No bill setup issues found.

+

Your audited bills have the core fields needed for tracking and snowball projections.

+
+
+
+ ) : ( +
+
+ +

Fix errors first; warnings are cleanup items that improve confidence and projections.

+
+
+ {sortedBills.map(bill => )} +
+
+ )} +
+ ); +} diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx index 3d36494..a1c0c16 100644 --- a/client/pages/LoginPage.jsx +++ b/client/pages/LoginPage.jsx @@ -81,6 +81,7 @@ export default function LoginPage() { const localEnabled = authMode.local_enabled !== false; const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url; const providerName = authMode.oidc_provider_name || 'authentik'; + const isAuthentikProvider = providerName.toLowerCase().includes('authentik'); const handleChangePassword = async (e) => { e.preventDefault(); @@ -148,6 +149,17 @@ export default function LoginPage() {

+ {oidcEnabled && isAuthentikProvider && ( +
+ authentik +
+ )} + {oidcEnabled && (