1446 lines
51 KiB
TypeScript
1446 lines
51 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useNavigate } from "react-router-dom";
|
||
import { useDashboard, type DashboardResponse } from "../hooks/useDashboard";
|
||
import { Money } from "../components/ui";
|
||
import PercentGuard from "../components/PercentGuard";
|
||
import MonthlyTrendChart from "../components/charts/MonthlyTrendChart";
|
||
import FixedFundingBars, {
|
||
type FixedItem,
|
||
} from "../components/charts/FixedFundingBars";
|
||
import VariableAllocationDonut, {
|
||
type VariableSlice,
|
||
} from "../components/charts/VariableAllocationDonut";
|
||
import { useAuthSession } from "../hooks/useAuthSession";
|
||
import { fixedPlansApi } from "../api/fixedPlans";
|
||
import { useToast } from "../components/Toast";
|
||
import PaydayOverlay from "../components/PaydayOverlay";
|
||
import PaymentReconciliationModal from "../components/PaymentReconciliationModal";
|
||
import FundingConfirmationModal from "../components/FundingConfirmationModal";
|
||
import EarlyPaymentPromptModal from "../components/EarlyPaymentPromptModal";
|
||
import { isoToDateString, dateStringToUTCMidnight, getTodayInTimezone, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone";
|
||
|
||
export default function DashboardPage() {
|
||
const session = useAuthSession();
|
||
const shouldLoadDashboard = !!session.data?.userId;
|
||
const trendMonths = 6;
|
||
const [trendPage, setTrendPage] = useState(0);
|
||
const { data, isLoading, isError, isFetching, error, refetch } = useDashboard(
|
||
shouldLoadDashboard,
|
||
{
|
||
trendPage,
|
||
trendMonths,
|
||
}
|
||
);
|
||
const navigate = useNavigate();
|
||
const { push } = useToast();
|
||
|
||
const [activeTab, setActiveTab] = useState<
|
||
"overview" | "variable" | "fixed" | "trend"
|
||
>("overview");
|
||
const [isDesktop, setIsDesktop] = useState(() => {
|
||
if (typeof window === "undefined") return true;
|
||
return window.matchMedia("(min-width: 1024px)").matches;
|
||
});
|
||
|
||
// Get user timezone from dashboard data
|
||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
||
const incomeType = data?.user?.incomeType ?? "regular";
|
||
const incomeFrequency = data?.user?.incomeFrequency;
|
||
const firstIncomeDate = data?.user?.firstIncomeDate ?? null;
|
||
|
||
const variableSlices: VariableSlice[] = useMemo(() => {
|
||
if (!data) return [];
|
||
return data.variableCategories.map((c) => ({
|
||
name: c.name,
|
||
value: c.percent,
|
||
isSavings: !!c.isSavings,
|
||
}));
|
||
}, [data]);
|
||
|
||
const fixedChartData: FixedItem[] = useMemo(() => {
|
||
if (!data) return [];
|
||
return data.fixedPlans.map((plan) => {
|
||
const total = plan.totalCents ?? 0;
|
||
const funded = Math.min(plan.fundedCents ?? 0, total);
|
||
const remaining = Math.max(total - funded, 0);
|
||
const aheadCents = getFundingAhead(plan) ?? 0;
|
||
|
||
// Calculate percentages for the chart (0-100 scale)
|
||
const fundedPercent = total > 0 ? (funded / total) * 100 : 0;
|
||
const remainingPercent = total > 0 ? (remaining / total) * 100 : 0;
|
||
|
||
return {
|
||
name: plan.name,
|
||
funded: fundedPercent,
|
||
remaining: remainingPercent,
|
||
fundedCents: funded,
|
||
remainingCents: remaining,
|
||
aheadCents,
|
||
isOverdue: (plan as { isOverdue?: boolean }).isOverdue ?? false,
|
||
dueOn: plan.dueOn,
|
||
};
|
||
});
|
||
}, [data]);
|
||
|
||
// Due-today overlay state
|
||
const [dueTodayPlan, setDueTodayPlan] = useState<
|
||
| { id: string; name: string; dueOn: string; remainingCents: number }
|
||
| null
|
||
>(null);
|
||
const [overrideDueISO, setOverrideDueISO] = useState<string>("");
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const media = window.matchMedia("(min-width: 1024px)");
|
||
const update = () => setIsDesktop(media.matches);
|
||
update();
|
||
media.addEventListener("change", update);
|
||
return () => {
|
||
media.removeEventListener("change", update);
|
||
};
|
||
}, []);
|
||
useEffect(() => {
|
||
if (!isDesktop && activeTab === "overview") {
|
||
setActiveTab("variable");
|
||
}
|
||
}, [isDesktop, activeTab]);
|
||
const [showDueOverlay, setShowDueOverlay] = useState(false);
|
||
const [isSubmittingDue, setIsSubmittingDue] = useState(false);
|
||
const [dueQueue, setDueQueue] = useState<
|
||
Array<{ id: string; name: string; dueOn: string; remainingCents: number }>
|
||
>([]);
|
||
const [queueIndex, setQueueIndex] = useState(0);
|
||
const savingsCategories = useMemo(
|
||
() => (data?.variableCategories || []).filter((c) => c.isSavings),
|
||
[data]
|
||
);
|
||
const savingsReserveById = useMemo(() => {
|
||
if (!data) return new Map<string, number>();
|
||
const reserveTotal = data.fixedPlans.reduce(
|
||
(sum, plan) => sum + (plan.fundedCents || 0),
|
||
0
|
||
);
|
||
const savingsTotalPercent = savingsCategories.reduce(
|
||
(sum, c) => sum + (c.percent || 0),
|
||
0
|
||
);
|
||
const shares = savingsCategories.map((c) => {
|
||
const raw = savingsTotalPercent
|
||
? (reserveTotal * (c.percent || 0)) / savingsTotalPercent
|
||
: 0;
|
||
const floored = Math.floor(raw);
|
||
return { id: c.id, share: floored, frac: raw - floored };
|
||
});
|
||
|
||
let remainder =
|
||
reserveTotal - shares.reduce((sum, item) => sum + item.share, 0);
|
||
shares
|
||
.slice()
|
||
.sort((a, b) => b.frac - a.frac)
|
||
.forEach((item) => {
|
||
if (remainder > 0) {
|
||
item.share += 1;
|
||
remainder -= 1;
|
||
}
|
||
});
|
||
|
||
return new Map(shares.map((item) => [item.id, item.share]));
|
||
}, [data, savingsCategories]);
|
||
const [fundingSource, setFundingSource] = useState<"savings" | "deficit">(
|
||
"savings"
|
||
);
|
||
const [savingsCategoryId, setSavingsCategoryId] = useState<string | null>(
|
||
null
|
||
);
|
||
const [hasCheckedDueToday, setHasCheckedDueToday] = useState(false);
|
||
|
||
// Funding confirmation modal state
|
||
const [showFundingConfirmation, setShowFundingConfirmation] = useState(false);
|
||
const [fundingData, setFundingData] = useState<{
|
||
planId: string;
|
||
planName: string;
|
||
totalCents: number;
|
||
fundedCents: number;
|
||
availableBudget: number;
|
||
} | null>(null);
|
||
|
||
// Payment reconciliation modal state
|
||
const [showPaymentReconciliation, setShowPaymentReconciliation] = useState(false);
|
||
const [reconciliationData, setReconciliationData] = useState<{
|
||
planId: string;
|
||
planName: string;
|
||
totalCents: number;
|
||
fundedCents: number;
|
||
isOverdue: boolean;
|
||
overdueAmount?: number;
|
||
message: string;
|
||
nextDueDate: string;
|
||
} | null>(null);
|
||
|
||
const [earlyPaymentPrompt, setEarlyPaymentPrompt] = useState<{
|
||
planId: string;
|
||
planName: string;
|
||
dueOn: string;
|
||
} | null>(null);
|
||
|
||
const openDueItem = async (item: { id: string; name: string; dueOn: string }) => {
|
||
const planDetail = data?.fixedPlans.find((p) => p.id === item.id);
|
||
const totalCents = planDetail?.totalCents ?? 0;
|
||
const fundedCents = planDetail?.fundedCents ?? 0;
|
||
const isFullyFunded = fundedCents >= totalCents;
|
||
|
||
if (!isFullyFunded) {
|
||
const availableBudget = data?.totals.variableBalanceCents ?? 0;
|
||
setFundingData({
|
||
planId: item.id,
|
||
planName: item.name,
|
||
totalCents,
|
||
fundedCents,
|
||
availableBudget,
|
||
});
|
||
setShowFundingConfirmation(true);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const fundingResult = await fixedPlansApi.attemptFinalFunding(item.id);
|
||
const freq = (planDetail as any)?.frequency;
|
||
const nextDueISO = computeNextDueDateISO(item.dueOn, freq);
|
||
const nextDueDate = nextDueISO
|
||
? isoToDateString(nextDueISO, userTimezone)
|
||
: item.dueOn.slice(0, 10);
|
||
|
||
setReconciliationData({
|
||
planId: item.id,
|
||
planName: item.name,
|
||
totalCents: fundingResult.totalCents,
|
||
fundedCents: fundingResult.fundedCents,
|
||
isOverdue: !!fundingResult.isOverdue,
|
||
overdueAmount: fundingResult.overdueAmount,
|
||
message: fundingResult.message ?? "",
|
||
nextDueDate: nextDueDate ?? getTodayInTimezone(userTimezone),
|
||
});
|
||
setShowPaymentReconciliation(true);
|
||
} catch (fundingError) {
|
||
console.error("[DueOverlay] attempt-final-funding failed", fundingError);
|
||
}
|
||
};
|
||
|
||
const advanceDueQueue = async () => {
|
||
const nextIdx = queueIndex + 1;
|
||
if (nextIdx < dueQueue.length) {
|
||
const nextItem = dueQueue[nextIdx];
|
||
setQueueIndex(nextIdx);
|
||
setDueTodayPlan(nextItem);
|
||
setOverrideDueISO(nextItem.dueOn.slice(0, 10));
|
||
const initialSavings = savingsCategories[0]?.id || null;
|
||
setSavingsCategoryId(initialSavings);
|
||
setFundingSource("savings");
|
||
await openDueItem(nextItem);
|
||
} else {
|
||
setDueTodayPlan(null);
|
||
setDueQueue([]);
|
||
setQueueIndex(0);
|
||
}
|
||
};
|
||
|
||
function toUserMidnight(iso: string, timezone: string) {
|
||
const dateStr = isoToDateString(iso, timezone);
|
||
return new Date(dateStringToUTCMidnight(dateStr, timezone));
|
||
}
|
||
|
||
function countPayPeriodsBetween(
|
||
startIso: string,
|
||
endIso: string,
|
||
firstIncomeIso: string,
|
||
frequency: NonNullable<typeof incomeFrequency>,
|
||
timezone: string
|
||
) {
|
||
let count = 0;
|
||
let nextPayDate = toUserMidnight(firstIncomeIso, timezone);
|
||
const normalizedStart = toUserMidnight(startIso, timezone);
|
||
const normalizedEnd = toUserMidnight(endIso, timezone);
|
||
|
||
const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1");
|
||
const advanceByPeriod = () => {
|
||
if (frequency === "monthly") {
|
||
const year = nextPayDate.getUTCFullYear();
|
||
const month = nextPayDate.getUTCMonth() + 1;
|
||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||
nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth)));
|
||
} else {
|
||
const days = frequency === "biweekly" ? 14 : 7;
|
||
nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000);
|
||
}
|
||
};
|
||
|
||
while (nextPayDate < normalizedStart) {
|
||
advanceByPeriod();
|
||
}
|
||
while (nextPayDate < normalizedEnd) {
|
||
count++;
|
||
advanceByPeriod();
|
||
}
|
||
return Math.max(1, count);
|
||
}
|
||
|
||
function getFundingAhead(plan: any) {
|
||
if (
|
||
incomeType !== "regular" ||
|
||
!incomeFrequency ||
|
||
!firstIncomeDate ||
|
||
!plan.cycleStart ||
|
||
!plan.dueOn
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const nowOverride = new URLSearchParams(window.location.search).get("debugNow");
|
||
const now = nowOverride ? new Date(`${nowOverride}T00:00:00.000Z`).toISOString() : new Date().toISOString();
|
||
let cycleStart = plan.cycleStart as string;
|
||
const dueOn = plan.dueOn as string;
|
||
|
||
let cycleStartDate: Date;
|
||
let dueDate: Date;
|
||
let nowDate: Date;
|
||
try {
|
||
cycleStartDate = toUserMidnight(cycleStart, userTimezone);
|
||
dueDate = toUserMidnight(dueOn, userTimezone);
|
||
nowDate = toUserMidnight(now, userTimezone);
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
if (cycleStartDate >= dueDate || cycleStartDate > nowDate) {
|
||
cycleStart = now;
|
||
}
|
||
|
||
const totalPeriods = countPayPeriodsBetween(
|
||
cycleStart,
|
||
dueOn,
|
||
firstIncomeDate,
|
||
incomeFrequency,
|
||
userTimezone
|
||
);
|
||
const elapsedPeriods = countPayPeriodsBetween(
|
||
cycleStart,
|
||
now,
|
||
firstIncomeDate,
|
||
incomeFrequency,
|
||
userTimezone
|
||
);
|
||
const targetFunded = Math.min(
|
||
plan.totalCents ?? 0,
|
||
Math.ceil(((plan.totalCents ?? 0) * elapsedPeriods) / totalPeriods)
|
||
);
|
||
const funded = plan.fundedCents ?? 0;
|
||
const aheadBy = Math.max(0, funded - targetFunded);
|
||
return aheadBy > 0 ? aheadBy : null;
|
||
}
|
||
|
||
// Helper: compute next due date from current due using user's timezone
|
||
function computeNextDueDateISO(dateISO: string, frequency?: string): string | null {
|
||
if (!frequency || frequency === "one-time") return null;
|
||
|
||
// Convert ISO to date string in user's timezone
|
||
const dateStr = isoToDateString(dateISO, userTimezone);
|
||
const [year, month, day] = dateStr.split('-').map(Number);
|
||
const date = new Date(Date.UTC(year, month - 1, day));
|
||
|
||
if (frequency === "weekly") {
|
||
date.setUTCDate(date.getUTCDate() + 7);
|
||
} else if (frequency === "biweekly") {
|
||
date.setUTCDate(date.getUTCDate() + 14);
|
||
} else if (frequency === "monthly") {
|
||
date.setUTCMonth(date.getUTCMonth() + 1);
|
||
}
|
||
|
||
// Convert back to ISO with timezone handling
|
||
const nextDateStr = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
|
||
return dateStringToUTCMidnight(nextDateStr, userTimezone);
|
||
}
|
||
|
||
// Detect if any fixed plan is due/overdue and needs user payment confirmation.
|
||
// Include fully funded overdue plans so missed-day confirmations still surface.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
async function checkDueToday() {
|
||
if (!data || !shouldLoadDashboard || hasCheckedDueToday) return;
|
||
try {
|
||
const res = await fixedPlansApi.due({ daysAhead: 0 });
|
||
const items = (res.items || [])
|
||
.filter((x) => x.isDue && (!x.isOverdue || x.remainingCents <= 0))
|
||
.filter((x) => {
|
||
const dismissedUntilStr = localStorage.getItem(`overdue-dismissed-${x.id}`);
|
||
if (!dismissedUntilStr) return true;
|
||
const dismissedUntil = parseInt(dismissedUntilStr, 10);
|
||
if (!Number.isFinite(dismissedUntil) || Date.now() >= dismissedUntil) {
|
||
localStorage.removeItem(`overdue-dismissed-${x.id}`);
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
if (!cancelled && items.length > 0) {
|
||
setHasCheckedDueToday(true);
|
||
const first = items[0];
|
||
|
||
await openDueItem(first);
|
||
|
||
// Store queue for potential future bills
|
||
setDueQueue(items.map((i) => ({
|
||
id: i.id,
|
||
name: i.name,
|
||
dueOn: i.dueOn,
|
||
remainingCents: i.remainingCents,
|
||
})));
|
||
setQueueIndex(0);
|
||
}
|
||
} catch (e: any) {
|
||
// Non-blocking: just ignore failures
|
||
try { console.warn("[DueOverlay] due check failed", e); } catch {}
|
||
}
|
||
}
|
||
checkDueToday();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [data, shouldLoadDashboard, hasCheckedDueToday, savingsCategories, userTimezone]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!data ||
|
||
showDueOverlay ||
|
||
showFundingConfirmation ||
|
||
showPaymentReconciliation ||
|
||
earlyPaymentPrompt
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const eligible = data.fixedPlans.find((plan: any) => {
|
||
const total = plan.totalCents ?? 0;
|
||
const funded = plan.fundedCents ?? 0;
|
||
if (total <= 0 || funded < total) return false;
|
||
if (plan.isOverdue) return false;
|
||
if (plan.needsFundingThisPeriod !== false) return false;
|
||
const ahead = getFundingAhead(plan);
|
||
if (!ahead || ahead <= 0) return false;
|
||
const key = `early-paid-prompt-${plan.id}-${plan.dueOn}`;
|
||
if (localStorage.getItem(key)) return false;
|
||
return true;
|
||
});
|
||
|
||
if (eligible) {
|
||
setEarlyPaymentPrompt({
|
||
planId: eligible.id,
|
||
planName: eligible.name,
|
||
dueOn: eligible.dueOn,
|
||
});
|
||
}
|
||
}, [
|
||
data,
|
||
showDueOverlay,
|
||
showFundingConfirmation,
|
||
showPaymentReconciliation,
|
||
earlyPaymentPrompt,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (data && !data.hasBudgetSetup) {
|
||
navigate("/onboarding", { replace: true });
|
||
}
|
||
}, [data?.hasBudgetSetup, navigate]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="p-6 space-y-4">
|
||
<div className="h-6 w-40 bg-[--color-panel] rounded animate-pulse" />
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{Array.from({ length: 3 }).map((_, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="h-24 rounded-xl bg-[--color-panel] animate-pulse"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isError || !data) {
|
||
return (
|
||
<div className="p-6 text-red-600 space-y-2">
|
||
<p>{(error as any)?.message || "Failed to load dashboard"}</p>
|
||
<button className="btn" onClick={() => refetch()}>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const currency = (cents: number) => (cents / 100).toFixed(2);
|
||
|
||
// CSV export removed per UI simplification request.
|
||
|
||
const hasCategories = data.variableCategories.length > 0;
|
||
const hasPlans = data.fixedPlans.length > 0;
|
||
const hasTx = data.recentTransactions.length > 0;
|
||
const hasTransactionsOlderThanSixMonths =
|
||
data.trendWindow?.hasTransactionsOlderThanSixMonths ?? false;
|
||
const useTrendWindowPagination = hasTransactionsOlderThanSixMonths;
|
||
const trendWindowLabel = data.trendWindow?.label ?? "Last 6 months";
|
||
const canTrendNewer = data.trendWindow?.canGoNewer ?? trendPage > 0;
|
||
const canTrendOlder = data.trendWindow?.canGoOlder ?? true;
|
||
|
||
const greetingName =
|
||
data.user.displayName ||
|
||
session.data?.displayName ||
|
||
session.data?.email?.split("@")[0] ||
|
||
"friend";
|
||
|
||
// Chart data for analytics section
|
||
|
||
return (
|
||
<div className="p-6 space-y-8 relative">
|
||
{/* Payday Overlay */}
|
||
<PaydayOverlay />
|
||
|
||
{/* Due Today Overlay */}
|
||
{showDueOverlay && dueTodayPlan && (
|
||
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||
<div className="space-y-1">
|
||
<h3 className="font-semibold text-lg">{dueTodayPlan.name} is due today</h3>
|
||
<p className="text-sm muted">
|
||
Confirm the due date and record the payment. We'll roll the plan forward automatically.
|
||
</p>
|
||
</div>
|
||
{(() => {
|
||
const planFull = data?.fixedPlans?.find((p) => p.id === dueTodayPlan.id);
|
||
// Extract frequency from plan.frequency or paymentSchedule.frequency
|
||
let freq = (planFull as any)?.frequency;
|
||
if (!freq && (planFull as any)?.paymentSchedule?.frequency) {
|
||
freq = (planFull as any).paymentSchedule.frequency;
|
||
}
|
||
const suggestedISO = planFull ? computeNextDueDateISO(dueTodayPlan.dueOn, freq) : null;
|
||
const suggestedDateOnly = suggestedISO ? suggestedISO.slice(0, 10) : "";
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="text-sm">Calculated next due date</div>
|
||
<div className="text-base font-mono">
|
||
{suggestedDateOnly || "n/a"}
|
||
</div>
|
||
<div className="text-xs muted">
|
||
If this looks correct, press Confirm. Or adjust below.
|
||
</div>
|
||
<label className="stack">
|
||
<span className="text-sm muted">Adjust next due date (optional)</span>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={overrideDueISO}
|
||
placeholder={suggestedDateOnly || dueTodayPlan.dueOn.slice(0,10)}
|
||
onChange={(e) => setOverrideDueISO(e.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
);
|
||
})()}
|
||
<div className="space-y-2">
|
||
<div className="text-sm">Remaining</div>
|
||
<div className="text-xl font-mono">
|
||
<Money cents={dueTodayPlan.remainingCents} />
|
||
</div>
|
||
<div className="text-xs muted">{queueIndex + 1} of {dueQueue.length}</div>
|
||
</div>
|
||
{dueTodayPlan.remainingCents > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="text-sm muted">Funding source</div>
|
||
<div className="row gap-2">
|
||
<button
|
||
type="button"
|
||
className={"nav-link " + (fundingSource === "savings" ? "nav-link-active" : "")}
|
||
onClick={() => setFundingSource("savings")}
|
||
disabled={isSubmittingDue || savingsCategories.length === 0}
|
||
>
|
||
Use savings
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={"nav-link " + (fundingSource === "deficit" ? "nav-link-active" : "")}
|
||
onClick={() => setFundingSource("deficit")}
|
||
disabled={isSubmittingDue}
|
||
>
|
||
Record deficit
|
||
</button>
|
||
</div>
|
||
{fundingSource === "savings" && (
|
||
<label className="stack">
|
||
<span className="text-sm muted">Savings category</span>
|
||
{savingsCategories.length > 0 ? (
|
||
<select
|
||
className="input"
|
||
value={savingsCategoryId ?? ""}
|
||
onChange={(e) => setSavingsCategoryId(e.target.value)}
|
||
>
|
||
{savingsCategories.map((c) => (
|
||
<option key={c.id} value={c.id}>{c.name}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<div className="toast-err">No savings categories available.</div>
|
||
)}
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3 pt-2">
|
||
<button
|
||
type="button"
|
||
className="btn bg-gray-600 hover:bg-gray-500"
|
||
onClick={() => {
|
||
// Skip this item
|
||
const nextIdx = queueIndex + 1;
|
||
if (nextIdx < dueQueue.length) {
|
||
const next = dueQueue[nextIdx];
|
||
setQueueIndex(nextIdx);
|
||
setDueTodayPlan(next);
|
||
setOverrideDueISO(next.dueOn.slice(0, 10));
|
||
const initialSavings = savingsCategories[0]?.id || null;
|
||
setSavingsCategoryId(initialSavings);
|
||
setFundingSource("savings");
|
||
} else {
|
||
setShowDueOverlay(false);
|
||
setDueTodayPlan(null);
|
||
setDueQueue([]);
|
||
setQueueIndex(0);
|
||
}
|
||
}}
|
||
disabled={isSubmittingDue}
|
||
>
|
||
Skip
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn bg-gray-600 hover:bg-gray-500"
|
||
onClick={() => {
|
||
setShowDueOverlay(false);
|
||
setDueTodayPlan(null);
|
||
setDueQueue([]);
|
||
setQueueIndex(0);
|
||
}}
|
||
disabled={isSubmittingDue}
|
||
>
|
||
Skip all
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn ml-auto"
|
||
onClick={async () => {
|
||
if (!dueTodayPlan) return;
|
||
setIsSubmittingDue(true);
|
||
try {
|
||
const body: any = {};
|
||
if (overrideDueISO) {
|
||
// Convert date-only (YYYY-MM-DD) to full ISO datetime (UTC midnight)
|
||
body.overrideDueOnISO = dateStringToUTCMidnight(overrideDueISO, userTimezone);
|
||
}
|
||
// Handle shortage funding
|
||
if (dueTodayPlan.remainingCents > 0) {
|
||
if (fundingSource === "savings") {
|
||
if (!savingsCategoryId) {
|
||
throw new Error("Select a savings category to cover the shortage.");
|
||
}
|
||
body.fundingSource = "savings";
|
||
body.savingsCategoryId = savingsCategoryId;
|
||
} else if (fundingSource === "deficit") {
|
||
body.fundingSource = "deficit";
|
||
}
|
||
}
|
||
const res = await fixedPlansApi.payNow(dueTodayPlan.id, body);
|
||
const next = res.nextDueOn
|
||
? formatDateInTimezone(res.nextDueOn, userTimezone)
|
||
: null;
|
||
push(
|
||
"ok",
|
||
next
|
||
? `Payment recorded. Next due ${next}.`
|
||
: "Payment recorded."
|
||
);
|
||
await refetch();
|
||
// Advance queue
|
||
const nextIdx = queueIndex + 1;
|
||
if (nextIdx < dueQueue.length) {
|
||
const nextItem = dueQueue[nextIdx];
|
||
setQueueIndex(nextIdx);
|
||
setDueTodayPlan(nextItem);
|
||
setOverrideDueISO(nextItem.dueOn.slice(0, 10));
|
||
const initialSavings = savingsCategories[0]?.id || null;
|
||
setSavingsCategoryId(initialSavings);
|
||
setFundingSource("savings");
|
||
} else {
|
||
setShowDueOverlay(false);
|
||
setDueTodayPlan(null);
|
||
setDueQueue([]);
|
||
setQueueIndex(0);
|
||
}
|
||
} catch (err: any) {
|
||
push("err", err?.message ?? "Failed to record payment.");
|
||
} finally {
|
||
setIsSubmittingDue(false);
|
||
}
|
||
}}
|
||
disabled={isSubmittingDue}
|
||
>
|
||
{isSubmittingDue ? "Recording…" : "Confirm & Pay Now"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Funding Confirmation Modal */}
|
||
{showFundingConfirmation && fundingData && (
|
||
<FundingConfirmationModal
|
||
planId={fundingData.planId}
|
||
planName={fundingData.planName}
|
||
totalCents={fundingData.totalCents}
|
||
fundedCents={fundingData.fundedCents}
|
||
availableBudget={fundingData.availableBudget}
|
||
onClose={() => {
|
||
setShowFundingConfirmation(false);
|
||
setFundingData(null);
|
||
}}
|
||
onFundingComplete={async (fundingResult) => {
|
||
setShowFundingConfirmation(false);
|
||
try {
|
||
const planDetail = data?.fixedPlans.find(p => p.id === fundingData.planId);
|
||
const freq = (planDetail as any)?.frequency;
|
||
const fallbackDueISO = dateStringToUTCMidnight(getTodayInTimezone(userTimezone), userTimezone);
|
||
const nextDueISO = computeNextDueDateISO(planDetail?.dueOn ?? fallbackDueISO, freq);
|
||
const nextDueDate = nextDueISO ? isoToDateString(nextDueISO, userTimezone) : getTodayInTimezone(userTimezone);
|
||
|
||
setReconciliationData({
|
||
planId: fundingData.planId,
|
||
planName: fundingData.planName,
|
||
totalCents: fundingResult.totalCents,
|
||
fundedCents: fundingResult.fundedCents,
|
||
isOverdue: !!fundingResult.isOverdue,
|
||
overdueAmount: fundingResult.overdueAmount,
|
||
message: fundingResult.message ?? "",
|
||
nextDueDate: nextDueDate ?? getTodayInTimezone(userTimezone),
|
||
});
|
||
setFundingData(null);
|
||
setShowPaymentReconciliation(true);
|
||
await refetch();
|
||
} catch (err) {
|
||
console.error("Failed to proceed to payment reconciliation", err);
|
||
push("err", "Failed to proceed with payment");
|
||
setFundingData(null);
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Payment Reconciliation Modal */}
|
||
{showPaymentReconciliation && reconciliationData && (
|
||
<PaymentReconciliationModal
|
||
planId={reconciliationData.planId}
|
||
planName={reconciliationData.planName}
|
||
totalCents={reconciliationData.totalCents}
|
||
fundedCents={reconciliationData.fundedCents}
|
||
isOverdue={reconciliationData.isOverdue}
|
||
overdueAmount={reconciliationData.overdueAmount}
|
||
message={reconciliationData.message}
|
||
nextDueDate={reconciliationData.nextDueDate}
|
||
onClose={() => {
|
||
setShowPaymentReconciliation(false);
|
||
setReconciliationData(null);
|
||
}}
|
||
onSuccess={async () => {
|
||
setShowPaymentReconciliation(false);
|
||
setReconciliationData(null);
|
||
// Clear any "remind me later" dismissal since they've handled it
|
||
if (reconciliationData?.planId) {
|
||
localStorage.removeItem(`overdue-dismissed-${reconciliationData.planId}`);
|
||
}
|
||
push("ok", "Payment recorded successfully");
|
||
await refetch();
|
||
if (dueQueue.length > 0) {
|
||
await advanceDueQueue();
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{earlyPaymentPrompt && (
|
||
<EarlyPaymentPromptModal
|
||
planId={earlyPaymentPrompt.planId}
|
||
planName={earlyPaymentPrompt.planName}
|
||
dueOn={earlyPaymentPrompt.dueOn}
|
||
timezone={userTimezone}
|
||
onClose={() => {
|
||
const key = `early-paid-prompt-${earlyPaymentPrompt.planId}-${earlyPaymentPrompt.dueOn}`;
|
||
localStorage.setItem(key, "dismissed");
|
||
setEarlyPaymentPrompt(null);
|
||
}}
|
||
onConfirmed={() => {
|
||
const key = `early-paid-prompt-${earlyPaymentPrompt.planId}-${earlyPaymentPrompt.dueOn}`;
|
||
localStorage.setItem(key, "confirmed");
|
||
refetch();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* HEADER */}
|
||
<header>
|
||
<h1 className="text-2xl font-bold">Hello, {greetingName}!</h1>
|
||
<p className="text-sm muted">
|
||
Snapshot of balances, plans, and recent activity.
|
||
</p>
|
||
</header>
|
||
|
||
{/* OVERDUE BILLS WARNING BANNER */}
|
||
{(() => {
|
||
const overduePlans = data.fixedPlans.filter((p: any) => p.isOverdue && (p.overdueAmount ?? 0) > 0);
|
||
if (overduePlans.length === 0) return null;
|
||
const totalOverdue = overduePlans.reduce((sum: number, p: any) => sum + (p.overdueAmount ?? 0), 0);
|
||
return (
|
||
<div className="bg-red-500/15 border-2 border-red-500 rounded-xl p-4 space-y-2">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||
<span className="text-2xl">!</span>
|
||
<div>
|
||
<div className="font-bold text-red-400">
|
||
{overduePlans.length === 1
|
||
? `${overduePlans[0].name} is overdue`
|
||
: `${overduePlans.length} bills are overdue`}
|
||
</div>
|
||
<div className="text-sm text-red-300">
|
||
Total outstanding: <Money cents={totalOverdue} /> · Post income to auto-fund
|
||
</div>
|
||
</div>
|
||
<Link to="/income" className="btn bg-red-500 hover:bg-red-600 w-full sm:w-auto sm:ml-auto">
|
||
+ Add Income
|
||
</Link>
|
||
</div>
|
||
{overduePlans.length > 1 && (
|
||
<div className="text-xs text-red-300 pl-9">
|
||
Priority order: {overduePlans
|
||
.sort((a: any, b: any) => {
|
||
const aTime = a.overdueSince ? new Date(a.overdueSince).getTime() : 0;
|
||
const bTime = b.overdueSince ? new Date(b.overdueSince).getTime() : 0;
|
||
return aTime - bTime;
|
||
})
|
||
.map((p: any) => `${p.name} ($${((p.overdueAmount ?? 0) / 100).toFixed(0)})`)
|
||
.join(" → ")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<PercentGuard />
|
||
|
||
{/* BUDGET OVERVIEW – key metrics */}
|
||
<section className="grid gap-4 md:grid-cols-3">
|
||
<Card label="Total Budget">
|
||
<Money cents={data.totals.incomeCents} />
|
||
</Card>
|
||
<Card label="Available Budget">
|
||
<Money cents={data.totals.variableBalanceCents} />
|
||
</Card>
|
||
<Card label="Funded Towards Fixed Plans">
|
||
<Money cents={data.fixedPlans.reduce((sum, plan) => sum + (plan.fundedCents || 0), 0)} />
|
||
</Card>
|
||
</section>
|
||
|
||
{/* ANALYTICS SECTION - Desktop: Show early with tabs */}
|
||
{isDesktop && (
|
||
<section className="space-y-4">
|
||
<div className="row items-center gap-3">
|
||
<h2 className="font-semibold">Analytics</h2>
|
||
<div className="row gap-1 ml-auto rounded-full bg-[--color-panel] p-1">
|
||
<AnalyticsTabButton
|
||
label="Overview"
|
||
active={activeTab === "overview"}
|
||
onClick={() => setActiveTab("overview")}
|
||
/>
|
||
<AnalyticsTabButton
|
||
label="Variable"
|
||
active={activeTab === "variable"}
|
||
onClick={() => setActiveTab("variable")}
|
||
/>
|
||
<AnalyticsTabButton
|
||
label="Fixed"
|
||
active={activeTab === "fixed"}
|
||
onClick={() => setActiveTab("fixed")}
|
||
/>
|
||
<AnalyticsTabButton
|
||
label="Trend"
|
||
active={activeTab === "trend"}
|
||
onClick={() => setActiveTab("trend")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab === "overview" && (
|
||
<div className="grid gap-6 lg:grid-cols-2">
|
||
<VariableAllocationDonut data={variableSlices} />
|
||
<FixedFundingBars data={fixedChartData} />
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "variable" && (
|
||
<VariableAllocationDonut data={variableSlices} />
|
||
)}
|
||
|
||
{activeTab === "fixed" && (
|
||
<FixedFundingBars data={fixedChartData} />
|
||
)}
|
||
|
||
{activeTab === "trend" && (
|
||
<section className="space-y-4">
|
||
{useTrendWindowPagination ? (
|
||
<TrendWindowControls
|
||
label={trendWindowLabel}
|
||
canGoOlder={canTrendOlder}
|
||
canGoNewer={canTrendNewer}
|
||
busy={isFetching}
|
||
onOlder={() => setTrendPage((p) => p + 1)}
|
||
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
|
||
/>
|
||
) : null}
|
||
<section className="grid gap-6 lg:grid-cols-2">
|
||
<MonthlyTrendChart data={data.monthlyTrend} />
|
||
<RecentTransactionsPanel
|
||
transactions={data.recentTransactions}
|
||
hasData={hasTx}
|
||
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
|
||
userTimezone={userTimezone}
|
||
/>
|
||
</section>
|
||
</section>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* UPCOMING PLAN ALERTS */}
|
||
{data.upcomingPlans.length > 0 && (
|
||
<UpcomingPlanAlerts plans={data.upcomingPlans} userTimezone={userTimezone} />
|
||
)}
|
||
|
||
{/* VARIABLE / FIXED LISTS */}
|
||
<section className="grid gap-6 lg:grid-cols-2">
|
||
<div className="space-y-3">
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<h2 className="font-semibold">Variable Categories</h2>
|
||
<span className="text-sm muted">
|
||
{data.percentTotal}%
|
||
</span>
|
||
</div>
|
||
{!hasCategories ? (
|
||
<EmptyState
|
||
message="No categories yet"
|
||
actionLabel="Create categories"
|
||
actionTo="/settings/categories"
|
||
/>
|
||
) : (
|
||
<div className="border rounded-xl divide-y">
|
||
{data.variableCategories.map((c) => {
|
||
return (
|
||
<div key={c.id} className={`p-3 space-y-2 ${c.isSavings ? 'bg-gradient-to-br from-emerald-50/10 via-emerald-50/5 to-transparent border-2 border-emerald-400/60 shadow-lg shadow-emerald-500/20 ring-1 ring-emerald-400/30' : ''}`}>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<div className="font-medium">{c.name}</div>
|
||
<div className="text-xs muted">
|
||
{c.isSavings ? "Savings • " : ""}
|
||
Priority {c.priority}
|
||
</div>
|
||
{c.isSavings &&
|
||
(c.savingsTargetCents ?? 0) > 0 && (
|
||
<div className="text-xs text-emerald-400 font-medium">
|
||
Goal ${currency(c.savingsTargetCents ?? 0)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
{c.isSavings ? (
|
||
<>
|
||
<Money
|
||
cents={
|
||
(c.balanceCents ?? 0) +
|
||
(savingsReserveById.get(c.id) ?? 0)
|
||
}
|
||
/>
|
||
<div className="text-xs muted">
|
||
Available <Money cents={c.balanceCents ?? 0} /> ·
|
||
Reserved{" "}
|
||
<Money cents={savingsReserveById.get(c.id) ?? 0} />
|
||
</div>
|
||
<div className="text-xs muted">{c.percent}%</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Money cents={c.balanceCents ?? 0} />
|
||
<div className="text-xs muted">{c.percent}%</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<ProgressBar value={c.percent} max={100} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<h2 className="font-semibold">Fixed plans</h2>
|
||
{!hasPlans ? (
|
||
<EmptyState
|
||
message="No plans configured"
|
||
actionLabel="Add a plan"
|
||
actionTo="/settings/plans"
|
||
/>
|
||
) : (
|
||
<div className="border rounded-xl divide-y">
|
||
{data.fixedPlans.map((p) => {
|
||
const total = p.totalCents ?? 0;
|
||
const funded = Math.min(p.fundedCents ?? 0, total);
|
||
const remaining = Math.max(total - funded, 0);
|
||
const pct = total
|
||
? Math.round((funded / total) * 100)
|
||
: 0;
|
||
const isOverdue = (p as any).isOverdue;
|
||
const overdueAmount = (p as any).overdueAmount ?? 0;
|
||
const aheadCents = getFundingAhead(p);
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className={`p-3 space-y-2 ${isOverdue ? 'bg-red-500/10 border-l-4 border-red-500' : ''}`}
|
||
>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="font-medium">{p.name}</div>
|
||
{isOverdue && (
|
||
<span className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded-full font-semibold">
|
||
OVERDUE
|
||
</span>
|
||
)}
|
||
{aheadCents !== null && (
|
||
<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full font-semibold">
|
||
Ahead {new Intl.NumberFormat("en-US", {
|
||
style: "currency",
|
||
currency: "USD",
|
||
maximumFractionDigits: 0,
|
||
}).format((aheadCents ?? 0) / 100)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-xs muted">
|
||
Due{" "}
|
||
{formatDateInTimezone(p.dueOn, userTimezone)} ·
|
||
Priority {p.priority}
|
||
</div>
|
||
{isOverdue && overdueAmount > 0 && (
|
||
<div className="text-xs text-red-400 font-medium mt-1">
|
||
Outstanding: <Money cents={overdueAmount} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-right text-sm">
|
||
Remaining <Money cents={remaining} />
|
||
</div>
|
||
</div>
|
||
<ProgressBar
|
||
value={funded}
|
||
max={total || 1}
|
||
label={`Funded ${pct}%`}
|
||
variant={isOverdue ? "danger" : "default"}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* SAVINGS GOALS */}
|
||
{data.savingsTargets.length > 0 && (
|
||
<SavingsGoalsPanel goals={data.savingsTargets} />
|
||
)}
|
||
|
||
{/* Mobile / tablet: tabbed analytics */}
|
||
<section className="space-y-4 lg:hidden">
|
||
<div className="row items-center gap-3">
|
||
<h2 className="font-semibold">Analytics</h2>
|
||
<div className="row gap-1 ml-auto rounded-full bg-[--color-panel] p-1">
|
||
<AnalyticsTabButton
|
||
label="Variable"
|
||
active={activeTab === "variable"}
|
||
onClick={() => setActiveTab("variable")}
|
||
/>
|
||
<AnalyticsTabButton
|
||
label="Fixed"
|
||
active={activeTab === "fixed"}
|
||
onClick={() => setActiveTab("fixed")}
|
||
/>
|
||
<AnalyticsTabButton
|
||
label="Trend"
|
||
active={activeTab === "trend"}
|
||
onClick={() => setActiveTab("trend")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{activeTab === "variable" && (
|
||
<VariableAllocationDonut data={variableSlices} />
|
||
)}
|
||
{activeTab === "fixed" && (
|
||
<FixedFundingBars data={fixedChartData} />
|
||
)}
|
||
{activeTab === "trend" && (
|
||
<section className="space-y-4">
|
||
{useTrendWindowPagination ? (
|
||
<TrendWindowControls
|
||
label={trendWindowLabel}
|
||
canGoOlder={canTrendOlder}
|
||
canGoNewer={canTrendNewer}
|
||
busy={isFetching}
|
||
onOlder={() => setTrendPage((p) => p + 1)}
|
||
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
|
||
/>
|
||
) : null}
|
||
<MonthlyTrendChart data={data.monthlyTrend} />
|
||
<RecentTransactionsPanel
|
||
transactions={data.recentTransactions}
|
||
hasData={hasTx}
|
||
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
|
||
userTimezone={userTimezone}
|
||
/>
|
||
</section>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ───────────────────────────
|
||
* Small components
|
||
* ───────────────────────────*/
|
||
|
||
function AnalyticsTabButton({
|
||
label,
|
||
active,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
active: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={
|
||
"px-3 py-1 rounded-full text-xs font-semibold transition " +
|
||
(active
|
||
? "bg-[--color-ink] text-[--color-bg]"
|
||
: "text-[--color-muted] hover:text-[--color-text]")
|
||
}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function TrendWindowControls({
|
||
label,
|
||
canGoOlder,
|
||
canGoNewer,
|
||
busy,
|
||
onOlder,
|
||
onNewer,
|
||
}: {
|
||
label: string;
|
||
canGoOlder: boolean;
|
||
canGoNewer: boolean;
|
||
busy: boolean;
|
||
onOlder: () => void;
|
||
onNewer: () => void;
|
||
}) {
|
||
return (
|
||
<div className="row items-center gap-2 rounded-xl border px-3 py-2 bg-[--color-panel]">
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
onClick={onNewer}
|
||
disabled={!canGoNewer || busy}
|
||
>
|
||
← Newer
|
||
</button>
|
||
<div className="text-sm muted ml-1">{label}</div>
|
||
<button
|
||
type="button"
|
||
className="btn ml-auto"
|
||
onClick={onOlder}
|
||
disabled={!canGoOlder || busy}
|
||
>
|
||
Older →
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UpcomingPlanAlerts({
|
||
plans,
|
||
userTimezone,
|
||
}: {
|
||
plans: { id: string; name: string; dueOn: string; remainingCents: number }[];
|
||
userTimezone: string;
|
||
}) {
|
||
if (plans.length === 0) return null;
|
||
return (
|
||
<section className="rounded-xl border border-amber-300/70 bg-amber-50/70 p-4 space-y-3 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-50">
|
||
Upcoming plan alerts (next 14 days)
|
||
</div>
|
||
<ul className="space-y-2">
|
||
{plans.map((plan) => (
|
||
<li
|
||
key={plan.id}
|
||
className="flex flex-col gap-2 rounded-lg bg-amber-100/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-200/15 dark:text-amber-50 sm:flex-row sm:items-center sm:justify-between"
|
||
>
|
||
<div>
|
||
<div className="font-medium text-amber-950 dark:text-white">{plan.name}</div>
|
||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||
Due{" "}
|
||
{formatDateInTimezone(plan.dueOn, userTimezone, {
|
||
month: "short",
|
||
day: "numeric",
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-xs text-amber-700 dark:text-amber-200">Remaining</div>
|
||
<Money cents={plan.remainingCents} />
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function SavingsGoalsPanel({
|
||
goals,
|
||
}: {
|
||
goals: Array<{
|
||
id: string;
|
||
name: string;
|
||
balanceCents: number;
|
||
targetCents: number;
|
||
percent: number;
|
||
}>;
|
||
}) {
|
||
return (
|
||
<section className="border rounded-xl p-4 space-y-3">
|
||
<h2 className="font-semibold">Savings goals</h2>
|
||
<ul className="space-y-3">
|
||
{goals.map((goal) => (
|
||
<li key={goal.id} className="space-y-1">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span>{goal.name}</span>
|
||
<span className="text-xs muted">{goal.percent}%</span>
|
||
</div>
|
||
<div className="h-2 rounded-full bg-[--color-panel] overflow-hidden shadow-inner">
|
||
<div
|
||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 shadow-sm"
|
||
style={{ width: `${Math.min(100, goal.percent)}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs muted">
|
||
<span>
|
||
<Money cents={goal.balanceCents} /> saved
|
||
</span>
|
||
<span>
|
||
Goal <Money cents={goal.targetCents} />
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function RecentTransactionsPanel({
|
||
transactions,
|
||
hasData,
|
||
rangeLabel,
|
||
userTimezone,
|
||
}: {
|
||
transactions: DashboardResponse["recentTransactions"];
|
||
hasData: boolean;
|
||
rangeLabel?: string;
|
||
userTimezone: string;
|
||
}) {
|
||
const title = rangeLabel ? "Transactions in window" : "Recent transactions";
|
||
const visibleTransactions = transactions.slice(0, 10);
|
||
const currentMonthLabel = new Intl.DateTimeFormat("en-US", {
|
||
month: "long",
|
||
year: "numeric",
|
||
timeZone: userTimezone,
|
||
}).format(new Date());
|
||
|
||
if (!hasData) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<h2 className="font-semibold">{title}</h2>
|
||
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
|
||
<div className="text-xs muted">
|
||
Tip: Records defaults to This month ({currentMonthLabel}).
|
||
</div>
|
||
<EmptyState
|
||
message="No transactions yet"
|
||
actionLabel="Record one"
|
||
actionTo="/spend"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="space-y-3">
|
||
<h2 className="font-semibold">{title}</h2>
|
||
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
|
||
<div className="text-xs muted">
|
||
Tip: Records defaults to This month ({currentMonthLabel}).
|
||
</div>
|
||
|
||
{/* Desktop table view */}
|
||
<div className="hidden sm:block border rounded-xl overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-[--color-panel]">
|
||
<tr>
|
||
<th className="text-left p-2">Date</th>
|
||
<th className="text-left p-2">Kind</th>
|
||
<th className="text-right p-2">Amount</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{visibleTransactions.map((tx) => (
|
||
<tr key={tx.id} className="border-t">
|
||
<td className="p-2">
|
||
{new Date(tx.occurredAt).toLocaleString()}
|
||
</td>
|
||
<td className="p-2 capitalize">
|
||
{tx.kind.replace("_", " ")}
|
||
</td>
|
||
<td className="p-2 text-right font-mono">
|
||
<Money cents={tx.amountCents} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Mobile card view */}
|
||
<div className="sm:hidden space-y-2">
|
||
{visibleTransactions.map((tx) => (
|
||
<div key={tx.id} className="border rounded-xl bg-[--color-panel] p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium capitalize">
|
||
{tx.kind.replace("_", " ")}
|
||
</div>
|
||
<div className="text-xs muted">
|
||
{new Date(tx.occurredAt).toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: 'numeric',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="text-base font-mono font-semibold">
|
||
<Money cents={tx.amountCents} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div>
|
||
<Link to="/transactions" className="btn text-sm">
|
||
View more
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Modernized KPI card
|
||
function Card({
|
||
label,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="card space-y-2">
|
||
<div className="text-xs uppercase tracking-[0.2em] muted">
|
||
{label}
|
||
</div>
|
||
<div className="text-2xl font-semibold">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProgressBar({
|
||
value,
|
||
max,
|
||
label,
|
||
variant = "default",
|
||
}: {
|
||
value: number;
|
||
max: number;
|
||
label?: string;
|
||
variant?: "default" | "danger";
|
||
}) {
|
||
const pct = Math.min(100, Math.round((value / Math.max(max, 1)) * 100));
|
||
const barColor = variant === "danger" ? "bg-red-500" : "bg-[--color-ink]";
|
||
return (
|
||
<div className="space-y-1">
|
||
{label && <div className="text-xs muted">{label}</div>}
|
||
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||
<div
|
||
className={`h-full ${barColor}`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({
|
||
message,
|
||
actionLabel,
|
||
actionTo,
|
||
}: {
|
||
message: string;
|
||
actionLabel: string;
|
||
actionTo: string;
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-dashed p-6 text-center space-y-2">
|
||
<p className="muted">{message}</p>
|
||
<Link to={actionTo} className="btn text-sm">
|
||
{actionLabel}
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|