final touches for beta skymoney (at least i think)
This commit is contained in:
@@ -1,31 +1,75 @@
|
||||
import React from "react";
|
||||
|
||||
export default function CurrencyInput({
|
||||
value,
|
||||
onValue,
|
||||
placeholder = "0.00",
|
||||
}: {
|
||||
type BaseProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange"
|
||||
> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type StringProps = BaseProps & {
|
||||
value: string;
|
||||
onValue: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const raw = e.target.value.replace(/[^0-9.]/g, "");
|
||||
// Keep only first dot, max 2 decimals
|
||||
const parts = raw.split(".");
|
||||
const cleaned =
|
||||
parts.length === 1
|
||||
? parts[0]
|
||||
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
|
||||
onValue(cleaned);
|
||||
valueCents?: never;
|
||||
onChange?: never;
|
||||
};
|
||||
|
||||
type CentsProps = BaseProps & {
|
||||
valueCents: number;
|
||||
onChange: (cents: number) => void;
|
||||
value?: never;
|
||||
onValue?: never;
|
||||
};
|
||||
|
||||
type Props = StringProps | CentsProps;
|
||||
|
||||
export default function CurrencyInput({
|
||||
className,
|
||||
placeholder = "0.00",
|
||||
...rest
|
||||
}: Props) {
|
||||
const mergedClass = ["input", className].filter(Boolean).join(" ");
|
||||
const formatString = (raw: string) => {
|
||||
const cleanedRaw = raw.replace(/[^0-9.]/g, "");
|
||||
const parts = cleanedRaw.split(".");
|
||||
return parts.length === 1
|
||||
? parts[0]
|
||||
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
|
||||
};
|
||||
|
||||
if ("valueCents" in rest) {
|
||||
const { valueCents, onChange, ...inputProps } = rest as CentsProps;
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formatted = formatString(e.target.value);
|
||||
const parsed = Number.parseFloat(formatted || "0");
|
||||
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0);
|
||||
};
|
||||
const displayValue = (valueCents ?? 0) / 100;
|
||||
const value = Number.isFinite(displayValue) ? displayValue.toString() : "";
|
||||
return (
|
||||
<input
|
||||
{...inputProps}
|
||||
className={mergedClass}
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onValue, ...inputProps } = rest as StringProps;
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onValue(formatString(e.target.value));
|
||||
};
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
{...inputProps}
|
||||
className={mergedClass}
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
81
web/src/components/EarlyFundingModal.tsx
Normal file
81
web/src/components/EarlyFundingModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { apiPatch } from "../api/http";
|
||||
import { formatDateInTimezone } from "../utils/timezone";
|
||||
|
||||
interface EarlyFundingModalProps {
|
||||
planId: string;
|
||||
planName: string;
|
||||
nextDueDate?: string;
|
||||
timezone: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EarlyFundingModal({ planId, planName, nextDueDate, timezone, onClose }: EarlyFundingModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleResponse = async (enableEarlyFunding: boolean) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiPatch(`/fixed-plans/${planId}/early-funding`, { enableEarlyFunding });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update early funding:", error);
|
||||
// Still close the modal even if it fails
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextDueLabel = nextDueDate
|
||||
? formatDateInTimezone(nextDueDate, timezone, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
: "next billing cycle";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Start Funding Early?
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
You've paid <strong>{planName}</strong> which is due on <strong>{nextDueLabel}</strong>.
|
||||
</p>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Would you like to start funding for the next payment now, or wait until closer to the due date?
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => handleResponse(true)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
|
||||
text-white font-medium rounded-md transition-colors"
|
||||
>
|
||||
{loading ? "Starting..." : "Yes, Start Now"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse(false)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700
|
||||
dark:hover:bg-gray-600 disabled:bg-gray-100 text-gray-900 dark:text-white
|
||||
font-medium rounded-md transition-colors"
|
||||
>
|
||||
Wait Until Rollover
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 pt-2">
|
||||
<strong>Start Now:</strong> Your next income will begin funding this bill.<br/>
|
||||
<strong>Wait:</strong> Funding will resume automatically on {nextDueLabel}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
web/src/components/EarlyPaymentPromptModal.tsx
Normal file
83
web/src/components/EarlyPaymentPromptModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { apiPatch } from "../api/http";
|
||||
import { formatDateInTimezone } from "../utils/timezone";
|
||||
|
||||
type EarlyPaymentPromptModalProps = {
|
||||
planId: string;
|
||||
planName: string;
|
||||
dueOn: string;
|
||||
timezone: string;
|
||||
onClose: () => void;
|
||||
onConfirmed: () => void;
|
||||
};
|
||||
|
||||
export default function EarlyPaymentPromptModal({
|
||||
planId,
|
||||
planName,
|
||||
dueOn,
|
||||
timezone,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: EarlyPaymentPromptModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const dueLabel = formatDateInTimezone(dueOn, timezone, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const handleConfirm = async (paidEarly: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (paidEarly) {
|
||||
await apiPatch(`/fixed-plans/${planId}/early-funding`, {
|
||||
enableEarlyFunding: true,
|
||||
});
|
||||
onConfirmed();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark paid early:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">Paid early?</h3>
|
||||
<p className="text-sm muted">
|
||||
{planName} is fully funded for the due date on {dueLabel}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[color:var(--color-muted)]">
|
||||
Did you actually pay this expense early in real life? If yes, we will
|
||||
move it to the next cycle so your budget stays accurate.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-gray-600 hover:bg-gray-500"
|
||||
onClick={() => handleConfirm(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
No, keep it
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ml-auto"
|
||||
onClick={() => handleConfirm(true)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating..." : "Yes, paid early"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
web/src/components/FundingConfirmationModal.tsx
Normal file
143
web/src/components/FundingConfirmationModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { fixedPlansApi } from "../api/fixedPlans";
|
||||
import { Money } from "./ui";
|
||||
|
||||
type FundingConfirmationModalProps = {
|
||||
planId: string;
|
||||
planName: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
availableBudget: number;
|
||||
onClose: () => void;
|
||||
onFundingComplete: (result: {
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
isOverdue?: boolean;
|
||||
overdueAmount?: number;
|
||||
message?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export default function FundingConfirmationModal({
|
||||
planId,
|
||||
planName,
|
||||
totalCents,
|
||||
fundedCents,
|
||||
availableBudget,
|
||||
onClose,
|
||||
onFundingComplete,
|
||||
}: FundingConfirmationModalProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const shortfall = totalCents - fundedCents;
|
||||
const canFund = availableBudget >= shortfall;
|
||||
|
||||
const handleRemindLater = () => {
|
||||
// Store dismissal timestamp in localStorage (4 hours from now)
|
||||
const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours
|
||||
localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePullFromBudget = async () => {
|
||||
setError("");
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const result = await fixedPlansApi.attemptFinalFunding(planId);
|
||||
onFundingComplete(result);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to fund from available budget");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg">{planName} is due today</h3>
|
||||
<p className="text-sm muted">
|
||||
This bill is not fully funded yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Funding Details */}
|
||||
<div className="space-y-2 p-3 bg-gray-800/30 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Total amount:</span>
|
||||
<span className="font-mono"><Money cents={totalCents} /></span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Currently funded:</span>
|
||||
<span className="font-mono"><Money cents={fundedCents} /></span>
|
||||
</div>
|
||||
<div className="border-t border-gray-700 pt-2 mt-2">
|
||||
<div className="flex justify-between text-sm font-semibold text-amber-700 dark:text-yellow-400">
|
||||
<span>Still needed:</span>
|
||||
<span className="font-mono"><Money cents={shortfall} /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Budget */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Available budget:</span>
|
||||
<span className="font-mono"><Money cents={availableBudget} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{canFund ? (
|
||||
<div className="text-sm text-center muted">
|
||||
Would you like to pull <Money cents={shortfall} /> from your available budget to fully fund this payment?
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
|
||||
Insufficient available budget. You need <Money cents={shortfall - availableBudget} /> more to fully fund this payment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-yellow-600 hover:bg-yellow-500"
|
||||
onClick={handleRemindLater}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Remind Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-gray-600 hover:bg-gray-500"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{canFund && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ml-auto"
|
||||
onClick={handlePullFromBudget}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Pull from Budget"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
web/src/components/NavBar.tsx
Normal file
102
web/src/components/NavBar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
export default function NavBar({
|
||||
hideOn = ["/onboarding", "/login", "/register"],
|
||||
}: {
|
||||
hideOn?: string[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
"nav-link " + (isActive ? "nav-link-active" : "");
|
||||
const mobileLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
"nav-link text-[--color-text] " +
|
||||
(isActive ? "nav-link-active" : "hover:bg-[--color-ink]/20");
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
if (hideOn.some((p) => pathname.startsWith(p))) return null;
|
||||
|
||||
return (
|
||||
<header
|
||||
className="topnav sticky top-0 z-40 border-b"
|
||||
style={{ backdropFilter: "blur(8px)" }}
|
||||
aria-label="Primary"
|
||||
>
|
||||
<div className="container h-14 min-h-14 flex items-center gap-2 flex-nowrap">
|
||||
{/* Brand */}
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="brand row items-center shrink-0 px-2 py-1 rounded-lg hover:bg-[--color-panel] transition-all"
|
||||
aria-label="Go to dashboard"
|
||||
>
|
||||
<span className="font-bold text-xl tracking-wide">SkyMoney</span>
|
||||
</button>
|
||||
|
||||
{/* Links */}
|
||||
<nav className="ml-2 topnav-links items-center gap-1 flex-1 overflow-x-auto whitespace-nowrap hide-scrollbar">
|
||||
<NavLink to="/" className={linkClass} end>Dashboard</NavLink>
|
||||
<NavLink to="/spend" className={linkClass}>Transactions</NavLink>
|
||||
<NavLink to="/income" className={linkClass}>Income</NavLink>
|
||||
<NavLink to="/transactions" className={linkClass}>Records</NavLink>
|
||||
<NavLink to="/settings" className={linkClass}>Settings</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="ml-auto row gap-2 items-center shrink-0">
|
||||
<div className="topnav-theme items-center gap-2">
|
||||
<ThemeToggle size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className="topnav-mobile relative">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"rounded-xl border border-[--color-border] bg-[--color-panel] px-3 py-2 text-[--color-text] transition " +
|
||||
(menuOpen ? "shadow-md" : "hover:bg-[--color-ink]/10")
|
||||
}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="grid gap-1">
|
||||
<span className="h-0.5 w-5 bg-current transition-all" />
|
||||
<span className="h-0.5 w-5 bg-current transition-all" />
|
||||
<span className="h-0.5 w-5 bg-current transition-all" />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id="mobile-menu"
|
||||
className={
|
||||
"absolute right-0 top-full mt-2 w-64 origin-top-right rounded-2xl border bg-[--color-surface] p-3 shadow-lg transition-all duration-200 ease-out " +
|
||||
(menuOpen
|
||||
? "opacity-100 translate-y-0"
|
||||
: "pointer-events-none opacity-0 -translate-y-2")
|
||||
}
|
||||
role="menu"
|
||||
aria-hidden={!menuOpen}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<NavLink to="/" className={mobileLinkClass} end>Dashboard</NavLink>
|
||||
<NavLink to="/spend" className={mobileLinkClass}>Transactions</NavLink>
|
||||
<NavLink to="/income" className={mobileLinkClass}>Income</NavLink>
|
||||
<NavLink to="/transactions" className={mobileLinkClass}>Records</NavLink>
|
||||
<NavLink to="/settings" className={mobileLinkClass}>Settings</NavLink>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[--color-border] pt-3 flex items-center justify-between">
|
||||
<ThemeToggle size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
398
web/src/components/OnboardingTracker.tsx
Normal file
398
web/src/components/OnboardingTracker.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useMemo } from "react";
|
||||
import { previewAllocation } from "../utils/allocatorPreview";
|
||||
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
|
||||
|
||||
type VariableCat = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings?: boolean;
|
||||
};
|
||||
|
||||
type FixedItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
amountCents: number;
|
||||
priority: number;
|
||||
dueOn: string;
|
||||
autoPayEnabled?: boolean;
|
||||
};
|
||||
|
||||
type OnboardingTrackerProps = {
|
||||
step: number;
|
||||
budgetCents: number;
|
||||
vars: VariableCat[];
|
||||
fixeds: FixedItem[];
|
||||
incomeType: "regular" | "irregular";
|
||||
budgetPeriod?: "weekly" | "biweekly" | "monthly";
|
||||
conservatismPercent?: number; // For irregular income: percentage to allocate to fixed expenses
|
||||
firstIncomeDate?: Date | string; // For accurate pay period calculation
|
||||
userTimezone?: string;
|
||||
};
|
||||
|
||||
const fmtMoney = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format((cents ?? 0) / 100);
|
||||
|
||||
// Calculate expected funding per paycheck for regular income users
|
||||
// Count actual pay periods between two dates, matching API logic
|
||||
function countPayPeriodsBetween(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
firstIncomeDate: Date,
|
||||
frequency: "weekly" | "biweekly" | "monthly",
|
||||
timezone: string
|
||||
): number {
|
||||
let count = 0;
|
||||
let nextPayDate = new Date(firstIncomeDate);
|
||||
|
||||
const targetDay = Number(isoToDateString(firstIncomeDate.toISOString(), 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Advance to the first pay date on or after startDate
|
||||
while (nextPayDate < startDate) {
|
||||
advanceByPeriod();
|
||||
}
|
||||
|
||||
// Count all pay dates up to (but not including) the end date
|
||||
while (nextPayDate < endDate) {
|
||||
count++;
|
||||
advanceByPeriod();
|
||||
}
|
||||
|
||||
// Ensure at least 1 period to avoid division by zero
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
function calculateExpectedFunding(
|
||||
totalCents: number,
|
||||
dueDate: string,
|
||||
incomeFrequency: "weekly" | "biweekly" | "monthly",
|
||||
firstIncomeDate?: Date | string,
|
||||
now = new Date(),
|
||||
timezone = getBrowserTimezone()
|
||||
): number {
|
||||
const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30;
|
||||
const todayIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
|
||||
const dueIso = dateStringToUTCMidnight(dueDate, timezone);
|
||||
|
||||
const due = new Date(dueIso);
|
||||
const userNow = new Date(todayIso);
|
||||
|
||||
let totalPaychecks: number;
|
||||
|
||||
if (firstIncomeDate) {
|
||||
// Use the same logic as the API: count actual pay dates
|
||||
const firstIncomeIso =
|
||||
typeof firstIncomeDate === "string"
|
||||
? firstIncomeDate.includes("T")
|
||||
? dateStringToUTCMidnight(isoToDateString(firstIncomeDate, timezone), timezone)
|
||||
: dateStringToUTCMidnight(firstIncomeDate, timezone)
|
||||
: dateStringToUTCMidnight(isoToDateString(firstIncomeDate.toISOString(), timezone), timezone);
|
||||
const firstIncome = new Date(firstIncomeIso);
|
||||
totalPaychecks = countPayPeriodsBetween(userNow, due, firstIncome, incomeFrequency, timezone);
|
||||
} else {
|
||||
// Fallback to simple calculation if firstIncomeDate not provided
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - userNow.getTime()) / DAY_MS));
|
||||
totalPaychecks = Math.max(1, Math.ceil(daysUntilDue / daysPerPaycheck));
|
||||
}
|
||||
|
||||
// Amount to fund per paycheck - use ceil to match API
|
||||
const perPaycheck = Math.ceil(totalCents / totalPaychecks);
|
||||
|
||||
return perPaycheck;
|
||||
}
|
||||
|
||||
export default function OnboardingTracker({
|
||||
step,
|
||||
budgetCents,
|
||||
vars,
|
||||
fixeds,
|
||||
incomeType,
|
||||
budgetPeriod = "monthly",
|
||||
conservatismPercent = 40,
|
||||
firstIncomeDate,
|
||||
userTimezone,
|
||||
}: OnboardingTrackerProps) {
|
||||
const timezone = userTimezone || getBrowserTimezone();
|
||||
// Only show tracker on steps 4, 5, and 6 (categories, fixed plans, review)
|
||||
const shouldShow = step >= 4;
|
||||
|
||||
// Calculate totals
|
||||
const eligibleFixeds = useMemo(
|
||||
() => fixeds.filter((f) => f.autoPayEnabled),
|
||||
[fixeds]
|
||||
);
|
||||
|
||||
const fixedTotal = useMemo(
|
||||
() => eligibleFixeds.reduce((sum, f) => sum + (f.amountCents || 0), 0),
|
||||
[eligibleFixeds]
|
||||
);
|
||||
|
||||
const varsTotal = useMemo(
|
||||
() => vars.reduce((sum, v) => sum + (v.percent || 0), 0),
|
||||
[vars]
|
||||
);
|
||||
|
||||
// Preview allocation if we have a budget
|
||||
const preview = useMemo(() => {
|
||||
if (budgetCents <= 0) return null;
|
||||
|
||||
// Convert onboarding data to the format expected by previewAllocation
|
||||
const fixedPlans = eligibleFixeds.map(f => {
|
||||
let totalCents = f.amountCents;
|
||||
let fundedCents = 0;
|
||||
|
||||
if (incomeType === "regular" && f.autoPayEnabled) {
|
||||
// Regular income: calculate per-paycheck amount based on due date
|
||||
totalCents = calculateExpectedFunding(f.amountCents, f.dueOn, budgetPeriod, firstIncomeDate, new Date(), timezone);
|
||||
}
|
||||
// For irregular income, we pass the full amount as the need
|
||||
// The conservatism will be applied by limiting the total budget passed to previewAllocation
|
||||
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
totalCents,
|
||||
fundedCents,
|
||||
dueOn: f.dueOn,
|
||||
priority: f.priority,
|
||||
cycleStart: f.dueOn,
|
||||
};
|
||||
});
|
||||
|
||||
const variableCategories = vars.map(v => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
percent: v.percent,
|
||||
balanceCents: 0,
|
||||
isSavings: v.isSavings || false,
|
||||
priority: v.priority,
|
||||
}));
|
||||
|
||||
// For irregular income, apply conservatism to split budget between fixed and variable
|
||||
if (incomeType === "irregular") {
|
||||
const fixedPercentage = conservatismPercent / 100;
|
||||
const fixedBudget = Math.floor(budgetCents * fixedPercentage);
|
||||
const variableBudget = budgetCents - fixedBudget;
|
||||
|
||||
// Allocate fixed budget to fixed plans (by priority)
|
||||
const fixedResult = previewAllocation(fixedBudget, fixedPlans, []);
|
||||
|
||||
// Allocate remaining budget to variables
|
||||
const variableResult = previewAllocation(variableBudget, [], variableCategories);
|
||||
|
||||
return {
|
||||
fixed: fixedResult.fixed,
|
||||
variable: variableResult.variable,
|
||||
unallocatedCents: fixedResult.unallocatedCents + variableResult.unallocatedCents,
|
||||
};
|
||||
}
|
||||
|
||||
// For regular income, use standard allocation (fixed first, then variable)
|
||||
return previewAllocation(budgetCents, fixedPlans, variableCategories);
|
||||
}, [
|
||||
budgetCents,
|
||||
eligibleFixeds,
|
||||
vars,
|
||||
incomeType,
|
||||
budgetPeriod,
|
||||
conservatismPercent,
|
||||
firstIncomeDate,
|
||||
timezone,
|
||||
]);
|
||||
|
||||
// Calculate actual fixed allocation amount from preview
|
||||
const fixedAllocated = preview
|
||||
? preview.fixed.reduce((sum, f) => sum + f.amountCents, 0)
|
||||
: 0;
|
||||
|
||||
const variableAmount = preview
|
||||
? preview.variable.reduce((sum, v) => sum + v.amountCents, 0)
|
||||
: budgetCents - fixedTotal;
|
||||
|
||||
const isValid = varsTotal === 100 && budgetCents > 0;
|
||||
|
||||
if (!shouldShow || budgetCents <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="onboarding-tracker">
|
||||
<div className="row items-center gap-2 pb-3 border-b border-[--color-ink]/20">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
|
||||
<h3 className="font-bold text-sm">Live Budget Tracker</h3>
|
||||
</div>
|
||||
|
||||
{/* Total Budget */}
|
||||
<div className="tracker-section tracker-budget">
|
||||
<div className="text-xs muted">Total Budget</div>
|
||||
<div className="font-bold text-xl sm:text-2xl font-mono">{fmtMoney(budgetCents)}</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">
|
||||
Available to allocate
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Expenses */}
|
||||
<div className="tracker-section tracker-fixed">
|
||||
<div className="row items-center justify-between">
|
||||
<div className="text-xs muted">Fixed Expenses</div>
|
||||
<div className="text-xs font-bold">{eligibleFixeds.length} items</div>
|
||||
</div>
|
||||
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(fixedTotal)}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (fixedAllocated / budgetCents) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs muted">
|
||||
{budgetCents > 0
|
||||
? `${Math.round((fixedAllocated / budgetCents) * 100)}% allocated`
|
||||
: "0% of budget"}
|
||||
</div>
|
||||
|
||||
{/* Individual fixed expenses breakdown */}
|
||||
{eligibleFixeds.length > 0 && (
|
||||
<div className="tracker-breakdown">
|
||||
<div className="text-xs font-semibold mb-1">Breakdown:</div>
|
||||
{eligibleFixeds.map((f) => {
|
||||
const previewItem = preview?.fixed.find(item => item.id === f.id);
|
||||
const totalAmount = f.amountCents;
|
||||
const fundedAmount = previewItem?.amountCents || 0;
|
||||
|
||||
const fundingPercentage = totalAmount > 0
|
||||
? Math.round((fundedAmount / totalAmount) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={f.id} className="stack gap-1 py-1">
|
||||
<div className="tracker-breakdown-row">
|
||||
<span className="tracker-breakdown-name">
|
||||
{f.name || "Unnamed"}
|
||||
</span>
|
||||
<span className="font-mono text-xs">
|
||||
{fmtMoney(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
{f.autoPayEnabled && fundedAmount > 0 && (
|
||||
<div className={`text-xs ${
|
||||
incomeType === "regular"
|
||||
? "text-green-700 dark:text-green-600"
|
||||
: "text-blue-600 dark:text-blue-400"
|
||||
}`}>
|
||||
{incomeType === "regular" ? "Auto" : "Plan"}: {fmtMoney(fundedAmount)} ({fundingPercentage}%)
|
||||
</div>
|
||||
)}
|
||||
{f.autoPayEnabled && fundedAmount === 0 && (
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Plan enabled (no funds yet)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable Categories */}
|
||||
<div className="tracker-section tracker-variable">
|
||||
<div className="row items-center justify-between">
|
||||
<div className="text-xs muted">Variable Budget</div>
|
||||
<div className="text-xs font-bold">{vars.length} categories</div>
|
||||
</div>
|
||||
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(variableAmount)}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (variableAmount / budgetCents) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs muted">
|
||||
{budgetCents > 0
|
||||
? `${Math.round((variableAmount / budgetCents) * 100)}% of budget`
|
||||
: "0% of budget"}
|
||||
</div>
|
||||
|
||||
{/* Individual variable categories breakdown */}
|
||||
{vars.length > 0 && preview && (
|
||||
<div className="tracker-breakdown">
|
||||
<div className="text-xs font-semibold mb-1">Breakdown:</div>
|
||||
{preview.variable.map((item) => {
|
||||
const originalVar = vars.find(v => v.id === item.id);
|
||||
const percentage = originalVar?.percent || 0;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="tracker-breakdown-row py-1">
|
||||
<span className="tracker-breakdown-name">
|
||||
{item.name || "Unnamed"}
|
||||
{originalVar?.isSavings && (
|
||||
<span className="text-green-700 dark:text-green-400 font-bold"> (Savings)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-xs whitespace-nowrap">
|
||||
{fmtMoney(item.amountCents)} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Percentage validation */}
|
||||
<div className="pt-2 border-t border-green-300 dark:border-green-700">
|
||||
<div className="row items-center justify-between">
|
||||
<span className="text-xs muted">Total percentage:</span>
|
||||
<span
|
||||
className={`text-xs font-bold ${
|
||||
varsTotal === 100
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{varsTotal}%
|
||||
</span>
|
||||
</div>
|
||||
{varsTotal !== 100 && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
Must equal 100%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remaining/Unallocated */}
|
||||
{preview && preview.unallocatedCents > 0 && (
|
||||
<div className="tracker-section bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="text-xs muted">Unallocated</div>
|
||||
<div className="font-bold text-lg font-mono text-gray-600 dark:text-gray-400">
|
||||
{fmtMoney(preview.unallocatedCents)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className={`text-center text-xs p-2 rounded-lg ${
|
||||
isValid
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300"
|
||||
}`}>
|
||||
{isValid ? "Ready to continue" : "Complete setup to continue"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Pagination({
|
||||
page,
|
||||
limit,
|
||||
|
||||
214
web/src/components/PaydayOverlay.tsx
Normal file
214
web/src/components/PaydayOverlay.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { http } from "../api/http";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { dateStringToUTCMidnight, formatDateInTimezone, getBrowserTimezone } from "../utils/timezone";
|
||||
import CurrencyInput from "./CurrencyInput";
|
||||
|
||||
type PaydayStatus = {
|
||||
shouldShowOverlay: boolean;
|
||||
pendingScheduledIncome: boolean;
|
||||
nextPayday: string | null;
|
||||
};
|
||||
|
||||
export default function PaydayOverlay() {
|
||||
const session = useAuthSession();
|
||||
const { data: dashboard } = useDashboard();
|
||||
const queryClient = useQueryClient();
|
||||
const [paydayStatus, setPaydayStatus] = useState<PaydayStatus | null>(null);
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [incomeCents, setIncomeCents] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
const debugNow = new URLSearchParams(window.location.search).get("debugNow");
|
||||
const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null;
|
||||
const debugNowDate = debugNowISO ? new Date(debugNowISO) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.data?.userId) return;
|
||||
|
||||
const checkPaydayStatus = async () => {
|
||||
try {
|
||||
const status = await http<PaydayStatus>("/payday/status", {
|
||||
query: debugNow ? { debugNow } : undefined,
|
||||
});
|
||||
setPaydayStatus(status);
|
||||
if (status.shouldShowOverlay) {
|
||||
setShowPopup(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check payday status:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPaydayStatus();
|
||||
}, [session.data?.userId]);
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await http("/payday/dismiss", { method: "POST" });
|
||||
setShowPopup(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to dismiss payday overlay:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddIncome = async () => {
|
||||
if (!incomeCents || incomeCents <= 0) {
|
||||
setError("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await http("/income", {
|
||||
method: "POST",
|
||||
body: {
|
||||
amountCents: incomeCents,
|
||||
isScheduledIncome: true,
|
||||
occurredAtISO: debugNowISO ?? new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh dashboard data
|
||||
await queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
|
||||
// Dismiss overlay
|
||||
await http("/payday/dismiss", { method: "POST" });
|
||||
setShowPopup(false);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to add income:", err);
|
||||
setError(err.message || "Failed to add income. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !showPopup || !paydayStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 animate-fade-in"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
|
||||
{/* Popup */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-md w-full p-6 pointer-events-auto animate-scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
It's Payday!
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDateInTimezone((debugNowDate ?? new Date()).toISOString(), userTimezone, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
||||
Ready to add your paycheck? Recording your scheduled income helps us:
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">OK</span>
|
||||
<span>Fund your payment plans automatically</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">OK</span>
|
||||
<span>Track your regular income vs bonuses</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">OK</span>
|
||||
<span>Keep your budget on schedule</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Income Input */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="income-amount" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Paycheck Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none">
|
||||
$
|
||||
</span>
|
||||
<CurrencyInput
|
||||
id="income-amount"
|
||||
valueCents={incomeCents}
|
||||
onChange={(cents) => {
|
||||
setIncomeCents(cents);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="0.00"
|
||||
className="w-full !pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddIncome}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSubmitting || !incomeCents}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add Income"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next payday info */}
|
||||
{paydayStatus.nextPayday && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Next expected payday:{" "}
|
||||
{formatDateInTimezone(paydayStatus.nextPayday, userTimezone, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
web/src/components/PaymentConfirmationModal.tsx
Normal file
51
web/src/components/PaymentConfirmationModal.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface PaymentConfirmationModalProps {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentConfirmationModal({
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PaymentConfirmationModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.showModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-3">Confirm Payment</h3>
|
||||
<p className="text-sm mb-4">{message}</p>
|
||||
|
||||
<div className="row gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
dialogRef.current?.close();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
dialogRef.current?.close();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
Confirm Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
239
web/src/components/PaymentReconciliationModal.tsx
Normal file
239
web/src/components/PaymentReconciliationModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from "react";
|
||||
import { fixedPlansApi } from "../api/fixedPlans";
|
||||
import { createTransaction } from "../api/transactions";
|
||||
|
||||
type PaymentReconciliationModalProps = {
|
||||
planId: string;
|
||||
planName: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
isOverdue: boolean;
|
||||
overdueAmount?: number;
|
||||
message: string;
|
||||
nextDueDate: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export default function PaymentReconciliationModal({
|
||||
planId,
|
||||
planName,
|
||||
totalCents,
|
||||
fundedCents,
|
||||
isOverdue,
|
||||
overdueAmount,
|
||||
message,
|
||||
nextDueDate,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PaymentReconciliationModalProps) {
|
||||
const [paymentType, setPaymentType] = useState<"full" | "partial" | "none">("full");
|
||||
const [partialAmount, setPartialAmount] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const formatMoney = (cents: number) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const handleRemindLater = () => {
|
||||
// Store dismissal timestamp in localStorage (4 hours from now)
|
||||
const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours
|
||||
localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (paymentType === "full") {
|
||||
// Full payment
|
||||
await createTransaction({
|
||||
kind: "fixed_payment",
|
||||
amountCents: totalCents,
|
||||
planId: planId,
|
||||
occurredAtISO: new Date().toISOString(),
|
||||
note: `Payment for ${planName}`,
|
||||
isReconciled: true,
|
||||
});
|
||||
onSuccess();
|
||||
} else if (paymentType === "partial") {
|
||||
// Partial payment
|
||||
const partialCents = Math.round(parseFloat(partialAmount) * 100);
|
||||
if (isNaN(partialCents) || partialCents <= 0) {
|
||||
setError("Please enter a valid amount");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (partialCents >= totalCents) {
|
||||
setError("Partial amount must be less than total");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await createTransaction({
|
||||
kind: "fixed_payment",
|
||||
amountCents: partialCents,
|
||||
planId: planId,
|
||||
occurredAtISO: new Date().toISOString(),
|
||||
note: `Partial payment for ${planName}`,
|
||||
isReconciled: true,
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
// No payment - mark as unpaid
|
||||
await fixedPlansApi.markUnpaid(planId);
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to process payment");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg">{planName} is due</h3>
|
||||
<p className="text-sm muted">
|
||||
{isOverdue
|
||||
? `Warning: ${message}`
|
||||
: "Was the full amount paid?"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan Details */}
|
||||
<div className="space-y-2 p-3 bg-gray-800/30 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Total amount:</span>
|
||||
<span className="font-mono">{formatMoney(totalCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Currently funded:</span>
|
||||
<span className="font-mono">{formatMoney(fundedCents)}</span>
|
||||
</div>
|
||||
{isOverdue && overdueAmount && overdueAmount > 0 && (
|
||||
<div className="flex justify-between text-sm text-red-400">
|
||||
<span>Overdue amount:</span>
|
||||
<span className="font-mono">{formatMoney(overdueAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Next due date:</span>
|
||||
<span className="font-mono">{nextDueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Options */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">How much was paid?</div>
|
||||
|
||||
{/* Full Payment */}
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-colors ${
|
||||
paymentType === "full"
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-gray-700 hover:border-gray-600"
|
||||
}`}
|
||||
onClick={() => setPaymentType("full")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<div className="font-medium">Full amount ({formatMoney(totalCents)})</div>
|
||||
<div className="text-xs muted">I paid the complete bill</div>
|
||||
</button>
|
||||
|
||||
{/* Partial Payment */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border-2 transition-colors ${
|
||||
paymentType === "partial"
|
||||
? "border-yellow-500 bg-yellow-500/10"
|
||||
: "border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left"
|
||||
onClick={() => setPaymentType("partial")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<div className="font-medium">Partial payment</div>
|
||||
<div className="text-xs muted">I paid some of the bill</div>
|
||||
</button>
|
||||
{paymentType === "partial" && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={(totalCents / 100).toFixed(2)}
|
||||
placeholder="0.00"
|
||||
className="input w-full"
|
||||
value={partialAmount}
|
||||
onChange={(e) => setPartialAmount(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No Payment */}
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-colors ${
|
||||
paymentType === "none"
|
||||
? "border-red-500 bg-red-500/10"
|
||||
: "border-gray-700 hover:border-gray-600"
|
||||
}`}
|
||||
onClick={() => setPaymentType("none")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<div className="font-medium">Not paid yet</div>
|
||||
<div className="text-xs muted">Mark as overdue, fund on next income</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
{isOverdue && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-yellow-600 hover:bg-yellow-500"
|
||||
onClick={handleRemindLater}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Remind Later
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-gray-600 hover:bg-gray-500"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ml-auto"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Processing..." : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
|
||||
export default function PercentGuard() {
|
||||
@@ -10,7 +9,7 @@ export default function PercentGuard() {
|
||||
|
||||
return (
|
||||
<div className="toast-err">
|
||||
Variable category percents must sum to <strong>100%</strong> (currently {total}%).
|
||||
Expense category percents must sum to <strong>100%</strong> (currently {total}%).
|
||||
Adjust them before recording income.
|
||||
</div>
|
||||
);
|
||||
|
||||
35
web/src/components/RequireAuth.tsx
Normal file
35
web/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
|
||||
type Props = { children: ReactNode };
|
||||
|
||||
export function RequireAuth({ children }: Props) {
|
||||
const location = useLocation();
|
||||
const session = useAuthSession();
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-sm muted">
|
||||
Checking session…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.isError) {
|
||||
const status = (session.error as any)?.status ?? 0;
|
||||
if (status === 401) {
|
||||
const next = encodeURIComponent(
|
||||
`${location.pathname}${location.search}`.replace(/^$/, "/")
|
||||
);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-sm text-red-500">
|
||||
Unable to verify session. Try refreshing.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
44
web/src/components/SessionTimeoutWarning.tsx
Normal file
44
web/src/components/SessionTimeoutWarning.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useSessionTimeout } from "../hooks/useSessionTimeout";
|
||||
|
||||
export function SessionTimeoutWarning() {
|
||||
const { state, timeRemaining, extendSession, logout } = useSessionTimeout();
|
||||
|
||||
if (state !== "warning") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-[--color-panel] border border-[--color-border] rounded-xl p-6 max-w-md mx-4 shadow-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">Session Expiring Soon</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm muted">
|
||||
Your session will expire in <strong className="text-amber-500">{timeRemaining} minute{timeRemaining !== 1 ? "s" : ""}</strong> due to inactivity.
|
||||
</p>
|
||||
|
||||
<p className="text-sm muted">
|
||||
Would you like to stay logged in?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
className="btn flex-1"
|
||||
onClick={extendSession}
|
||||
>
|
||||
Stay Logged In
|
||||
</button>
|
||||
<button
|
||||
className="btn bg-red-500/10 border-red-500/40 text-red-200 hover:bg-red-500/20 flex-1"
|
||||
onClick={logout}
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
web/src/components/ThemeToggle.tsx
Normal file
30
web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
export default function ThemeToggle({ size = "md" }: { size?: "sm" | "md" }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const opts: Array<"dark" | "light" | "system"> = ["dark", "light", "system"];
|
||||
const base =
|
||||
size === "sm"
|
||||
? "text-[11px] px-2 py-1 rounded-lg"
|
||||
: "text-xs px-2 py-1 rounded-xl";
|
||||
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1 border bg-[--color-panel] " + base}>
|
||||
{opts.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setTheme(opt)}
|
||||
aria-pressed={theme === opt}
|
||||
className={
|
||||
base +
|
||||
" transition " +
|
||||
(theme === opt ? "bg-[--color-ink] text-[--color-bg]" : "hover:bg-[--color-ink]/10")
|
||||
}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
|
||||
|
||||
type Toast = { id: string; kind: "ok" | "err"; message: string };
|
||||
type Ctx = { push: (kind: Toast["kind"], message: string) => void };
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { setUserId, getUserId } from "../api/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function UserSwitcher() {
|
||||
const qc = useQueryClient();
|
||||
const [val, setVal] = useState(getUserId());
|
||||
const apply = () => {
|
||||
setUserId(val);
|
||||
qc.invalidateQueries(); // why: reload all data for new tenant
|
||||
};
|
||||
return (
|
||||
<div className="row">
|
||||
<input
|
||||
className="input w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
title="Dev User Id"
|
||||
/>
|
||||
<button className="btn" type="button" onClick={apply}>Use</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from "recharts";
|
||||
import { fmtMoney } from "../../utils/money";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type FixedItem = { name: string; funded: number; remaining: number };
|
||||
export type FixedItem = {
|
||||
name: string;
|
||||
funded: number;
|
||||
remaining: number;
|
||||
fundedCents: number;
|
||||
remainingCents: number;
|
||||
aheadCents?: number;
|
||||
isOverdue?: boolean;
|
||||
};
|
||||
|
||||
export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
if (!data.length) {
|
||||
@@ -11,22 +21,89 @@ export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { ref, isInView } = useInView();
|
||||
const colorPalette = [
|
||||
"#3B82F6",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#06B6D4",
|
||||
"#F97316",
|
||||
"#EC4899",
|
||||
"#84CC16",
|
||||
];
|
||||
const toRgba = (hex: string, alpha: number) => {
|
||||
const cleaned = hex.replace("#", "");
|
||||
const bigint = parseInt(cleaned, 16);
|
||||
const r = (bigint >> 16) & 255;
|
||||
const g = (bigint >> 8) & 255;
|
||||
const b = bigint & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
|
||||
<div className="chart-lg">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data} stackOffset="expand">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="name" stroke="#94a3b8" />
|
||||
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} stroke="#94a3b8" />
|
||||
<Tooltip formatter={(v: number) => `${Math.round(Number(v) * 100)}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
|
||||
<Legend />
|
||||
<Bar dataKey="funded" stackId="a" fill="#165F46" name="Funded" />
|
||||
<Bar dataKey="remaining" stackId="a" fill="#374151" name="Remaining" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="chart-lg" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={320}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="name" stroke="#94a3b8" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} stroke="#94a3b8" />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const row = payload[0]?.payload as FixedItem | undefined;
|
||||
if (!row) return null;
|
||||
const fundedLabel = `${Math.round(row.funded)}% (${fmtMoney(row.fundedCents)})`;
|
||||
const remainingLabel = `${Math.round(row.remaining)}% (${fmtMoney(row.remainingCents)})`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: 8,
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{row.name}</div>
|
||||
<div>Funded: {fundedLabel}</div>
|
||||
<div>Remaining: {remainingLabel}</div>
|
||||
{row.isOverdue && (
|
||||
<div style={{ marginTop: 6, color: "#FCA5A5" }}>Overdue</div>
|
||||
)}
|
||||
{!row.isOverdue && row.aheadCents && row.aheadCents > 0 && (
|
||||
<div style={{ marginTop: 6, color: "#86EFAC" }}>
|
||||
Ahead {fmtMoney(row.aheadCents)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="funded" stackId="a" name="Funded">
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`funded-${entry.name}`} fill={colorPalette[index % colorPalette.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
<Bar dataKey="remaining" stackId="a" name="Remaining">
|
||||
{data.map((entry, index) => {
|
||||
const base = colorPalette[index % colorPalette.length];
|
||||
return (
|
||||
<Cell key={`remaining-${entry.name}`} fill={toRgba(base, 0.35)} />
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
web/src/components/charts/MonthlyTrendChart.tsx
Normal file
58
web/src/components/charts/MonthlyTrendChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type TrendPoint = { monthKey: string; label: string; incomeCents: number; spendCents: number };
|
||||
|
||||
const currency = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
export default function MonthlyTrendChart({ data }: { data: TrendPoint[] }) {
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="section-title mb-2">Monthly Trend</h3>
|
||||
<div className="muted text-sm">No data yet. Add income or spending to see history.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = data.map((point) => ({
|
||||
label: point.label,
|
||||
income: point.incomeCents / 100,
|
||||
spend: point.spendCents / 100,
|
||||
}));
|
||||
const { ref, isInView } = useInView();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="section-title mb-2">Monthly Trend</h3>
|
||||
<div className="chart-lg" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={320}>
|
||||
<LineChart data={normalized} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="label" stroke="#94a3b8" />
|
||||
<YAxis stroke="#94a3b8" tickFormatter={(v) => currency(v)} width={90} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => currency(value)}
|
||||
contentStyle={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: 8,
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500"
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="income" name="Income" stroke="#16a34a" strokeWidth={3} dot={{ r: 4 }} />
|
||||
<Line type="monotone" dataKey="spend" name="Spend" stroke="#f97316" strokeWidth={3} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
// web/src/components/charts/VariableAllocationDonut.tsx
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type VariableSlice = { name: string; value: number; isSavings: boolean };
|
||||
|
||||
export default function VariableAllocationDonut({ data }: { data: VariableSlice[] }) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
const savingsTotal = data.filter((d) => d.isSavings).reduce((s, d) => s + d.value, 0);
|
||||
const savingsPercent = total > 0 ? Math.round((savingsTotal / total) * 100) : 0;
|
||||
const { ref, isInView } = useInView();
|
||||
if (!data.length || total === 0) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -14,21 +18,80 @@ export default function VariableAllocationDonut({ data }: { data: VariableSlice[
|
||||
);
|
||||
}
|
||||
|
||||
const fillFor = (s: boolean) => (s ? "#165F46" : "#374151");
|
||||
// Color palette for variable categories with savings highlighting
|
||||
const colorPalette = [
|
||||
"#3B82F6", // bright blue
|
||||
"#F59E0B", // amber/gold
|
||||
"#EF4444", // red
|
||||
"#8B5CF6", // purple
|
||||
"#06B6D4", // cyan
|
||||
"#F97316", // orange
|
||||
"#EC4899", // pink
|
||||
"#84CC16", // lime green
|
||||
];
|
||||
|
||||
const savingsColors = {
|
||||
primary: "#10B981", // emerald-500 (brighter)
|
||||
accent: "#059669", // emerald-600 (darker alternate)
|
||||
};
|
||||
|
||||
const getColor = (index: number, isSavings: boolean) => {
|
||||
if (isSavings) {
|
||||
return index % 2 === 0 ? savingsColors.primary : savingsColors.accent;
|
||||
}
|
||||
return colorPalette[index % colorPalette.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
|
||||
<div className="chart-md">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
|
||||
{data.map((d, i) => <Cell key={i} fill={fillFor(d.isSavings)} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => `${v}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
|
||||
<Legend verticalAlign="bottom" height={24} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="chart-md" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={240}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
|
||||
{data.map((d, i) => <Cell key={i} fill={getColor(i, d.isSavings)} />)}
|
||||
</Pie>
|
||||
<text x="50%" y="46%" textAnchor="middle" dominantBaseline="middle" fill="var(--color-text)" fontSize="16" fontWeight="600">
|
||||
{Math.round(total)}%
|
||||
</text>
|
||||
<text x="50%" y="58%" textAnchor="middle" dominantBaseline="middle" fill="var(--color-muted)" fontSize="12" fontWeight="500">
|
||||
Savings {savingsPercent}%
|
||||
</text>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div style={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)"
|
||||
}}>
|
||||
<p style={{ margin: 0, color: "#F9FAFB" }}>
|
||||
{data.name}: {data.value}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={24}
|
||||
wrapperStyle={{ color: "#F9FAFB" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { fmtMoney } from "../utils/money";
|
||||
|
||||
export function Money({ cents }: { cents: number }) {
|
||||
|
||||
Reference in New Issue
Block a user