Add customizable dashboard with drag-and-drop section reordering

- Added Capsule app icon and branding assets
- Implemented dashboard customization panel with drag-and-drop interface
- Added budget progress tracking and quick actions sections
- Enhanced dashboard with clickable summary cards (Assets, Debt, Balance, Envelopes, Transactions)
- Increased spacing between sections for better readability
- Users can now show/hide and reorder dashboard sections
- Fixed account balance normalization for liabilities
- Improved analytics charts with visibility controls
This commit is contained in:
nmemmert committed 2026-01-13 15:40:23 -05:00
1 parent 2dcd7b29fb
commit 4d7f301c61
11 files changed
+1823 -137

No files matched your search

+17 -3
View File
@@ -29,6 +29,14 @@ const accountTypes = [
{ value: 'loan', label: 'Loan', icon: '📋' },
];
const liabilityTypes: Account['type'][] = ['credit_card', 'mortgage', 'loan'];
const normalizeBalanceByType = (type: Account['type'], balance: number): number => {
if (liabilityTypes.includes(type)) {
return -Math.abs(balance || 0);
}
return balance || 0;
};
const accountColors = [
'bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-orange-500',
'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500'
@@ -63,11 +71,13 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
return;
}
const normalizedBalance = normalizeBalanceByType(formData.type as Account['type'], formData.balance || 0);
const account: Account = {
id: editingAccount?.id || `${formData.type}-${Date.now()}`,
name: formData.name,
type: formData.type as Account['type'],
balance: formData.balance || 0,
balance: normalizedBalance,
institution: formData.institution,
accountNumber: formData.accountNumber,
color: formData.color || 'bg-blue-500',
@@ -124,7 +134,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h3 className="text-sm font-medium text-gray-500">Total Balance</h3>
<p className="text-2xl font-bold text-green-600">${totalBalance.toFixed(2)}</p>
<p className={`text-2xl font-bold ${totalBalance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${totalBalance.toFixed(2)}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h3 className="text-sm font-medium text-gray-500">Active Accounts</h3>
@@ -167,7 +179,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
<div className="flex items-center space-x-4">
<div className="text-right">
<p className="font-semibold text-gray-900">${account.balance.toFixed(2)}</p>
<p className={`font-semibold ${account.balance < 0 ? 'text-red-600' : 'text-gray-900'}`}>
${account.balance.toFixed(2)}
</p>
<p className={`text-sm ${account.isActive ? 'text-green-600' : 'text-gray-500'}`}>
{account.isActive ? 'Active' : 'Inactive'}
</p>
+166 -3
View File
@@ -3,6 +3,7 @@
interface Account {
id: string;
name: string;
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
balance: number;
color: string;
}
@@ -26,9 +27,33 @@ interface DashboardChartsProps {
accounts: Account[];
envelopes: Envelope[];
transactions: Transaction[];
visibleCharts?: {
incomeExpenses: boolean;
budgetAlerts: boolean;
assetsDebt: boolean;
envelopeSpending: boolean;
accountBalances: boolean;
dailySpending: boolean;
};
}
export default function DashboardCharts({ accounts, envelopes, transactions }: DashboardChartsProps) {
const assetTypes: Account['type'][] = ['checking', 'savings', 'investment'];
const debtTypes: Account['type'][] = ['credit_card', 'mortgage', 'loan'];
export default function DashboardCharts({ accounts, envelopes, transactions, visibleCharts = {
incomeExpenses: true,
budgetAlerts: true,
assetsDebt: true,
envelopeSpending: true,
accountBalances: true,
dailySpending: true,
} }: DashboardChartsProps) {
// Separate accounts into assets and debt
const assetAccounts = accounts.filter(acc => assetTypes.includes(acc.type));
const debtAccounts = accounts.filter(acc => debtTypes.includes(acc.type));
const totalAssets = assetAccounts.reduce((sum, acc) => sum + acc.balance, 0);
const totalDebt = Math.abs(debtAccounts.reduce((sum, acc) => sum + acc.balance, 0));
// Calculate spending by envelope
const envelopeSpending = envelopes.map(env => ({
name: env.name,
@@ -38,6 +63,16 @@ export default function DashboardCharts({ accounts, envelopes, transactions }: D
percentage: env.allocated > 0 ? (Math.abs(env.spent) / env.allocated) * 100 : 0
})).sort((a, b) => b.spent - a.spent);
// Find envelopes with alerts (overspent or near limit >80%)
const envelopeAlerts = envelopes.filter(env => {
const percentage = env.allocated > 0 ? (Math.abs(env.spent) / env.allocated) * 100 : 0;
return percentage >= 80;
}).sort((a, b) => {
const aPerc = a.allocated > 0 ? (Math.abs(a.spent) / a.allocated) * 100 : 0;
const bPerc = b.allocated > 0 ? (Math.abs(b.spent) / b.allocated) * 100 : 0;
return bPerc - aPerc;
});
// Calculate total balance across accounts
const totalBalance = accounts.reduce((sum, acc) => sum + acc.balance, 0);
const accountBalances = accounts.map(acc => ({
@@ -47,8 +82,27 @@ export default function DashboardCharts({ accounts, envelopes, transactions }: D
percentage: totalBalance > 0 ? (acc.balance / totalBalance) * 100 : 0
}));
// Calculate spending over time (last 30 days)
// Calculate income vs expenses (this month)
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const monthTransactions = transactions.filter(t => {
const txDate = t.date instanceof Date ? t.date : new Date(t.date);
return txDate >= monthStart && txDate <= monthEnd;
});
const totalIncome = monthTransactions
.filter(t => t.amount > 0 && !t.envelopeId)
.reduce((sum, t) => sum + t.amount, 0);
const totalExpenses = monthTransactions
.filter(t => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const netIncome = totalIncome - totalExpenses;
// Calculate spending over time (last 30 days)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const recentTransactions = transactions.filter(t => {
@@ -72,8 +126,112 @@ export default function DashboardCharts({ accounts, envelopes, transactions }: D
const maxDailySpending = Math.max(...spendingData.map(([_, amount]) => amount), 1);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Income vs Expenses Card */}
{visibleCharts.incomeExpenses && (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Income vs Expenses (This Month)</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Income</span>
<span className="text-xl font-bold text-green-600">${totalIncome.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Expenses</span>
<span className="text-xl font-bold text-red-600">${totalExpenses.toFixed(2)}</span>
</div>
<div className="pt-3 border-t border-gray-200">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-900">Net Income</span>
<span className={`text-xl font-bold ${netIncome >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${netIncome.toFixed(2)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Budget Alerts Card */}
{visibleCharts.budgetAlerts && (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Budget Alerts</h3>
{envelopeAlerts.length > 0 ? (
<div className="space-y-2">
{envelopeAlerts.slice(0, 5).map((env) => {
const percentage = env.allocated > 0 ? (Math.abs(env.spent) / env.allocated) * 100 : 0;
const isOverspent = percentage > 100;
return (
<div
key={env.id}
className={`p-3 rounded-lg border-l-4 ${
isOverspent
? 'bg-red-50 border-red-500'
: 'bg-yellow-50 border-yellow-500'
}`}
>
<div className="flex justify-between items-start">
<div>
<p className={`font-medium ${isOverspent ? 'text-red-900' : 'text-yellow-900'}`}>
{env.name}
</p>
<p className={`text-sm ${isOverspent ? 'text-red-700' : 'text-yellow-700'}`}>
{isOverspent
? `Over budget by $${(Math.abs(env.spent) - env.allocated).toFixed(2)}`
: `${percentage.toFixed(0)}% of budget used`}
</p>
</div>
<span className={`text-sm font-semibold ${isOverspent ? 'text-red-600' : 'text-yellow-600'}`}>
{percentage.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">✓ All envelopes are within budget</p>
)}
</div>
)}
{/* Assets & Debt Summary */}
{visibleCharts.assetsDebt && (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assets vs Debt</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-700">Total Assets</span>
<span className="font-bold text-green-600">${totalAssets.toFixed(2)}</span>
</div>
<div className="text-xs text-gray-500 ml-1">
{assetAccounts.map(acc => acc.name).join(', ') || 'None'}
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-700">Total Debt</span>
<span className="font-bold text-red-600">${totalDebt.toFixed(2)}</span>
</div>
<div className="text-xs text-gray-500 ml-1">
{debtAccounts.map(acc => acc.name).join(', ') || 'None'}
</div>
</div>
<div className="pt-3 border-t border-gray-200">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-900">Net Worth</span>
<span className={`text-lg font-bold ${totalAssets - totalDebt >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${(totalAssets - totalDebt).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Envelope Spending Chart */}
{visibleCharts.envelopeSpending && (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Envelope Spending</h3>
{envelopeSpending.length > 0 ? (
@@ -104,8 +262,10 @@ export default function DashboardCharts({ accounts, envelopes, transactions }: D
<p className="text-gray-500 text-sm">No envelope spending data yet.</p>
)}
</div>
)}
{/* Account Balances Chart */}
{visibleCharts.accountBalances && (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Balances</h3>
{accountBalances.length > 0 ? (
@@ -139,8 +299,10 @@ export default function DashboardCharts({ accounts, envelopes, transactions }: D
<p className="text-gray-500 text-sm">No account data yet.</p>
)}
</div>
)}
{/* Daily Spending Chart (Last 7 Days) */}
{visibleCharts.dailySpending && (
<div className="bg-white rounded-lg shadow-sm border p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Daily Spending (Last 7 Days)</h3>
{spendingData.length > 0 ? (
@@ -172,6 +334,7 @@ export default function DashboardCharts({ accounts, envelopes, transactions }: D
<p className="text-gray-500 text-sm">No spending data for the last 7 days.</p>
)}
</div>
)}
</div>
);
}
+11 -12
View File
@@ -364,18 +364,11 @@ export default function GetPaid({
required
>
<option value="">Select account...</option>
{accounts
.filter(
(account) =>
account.type !== 'credit_card' &&
account.type !== 'mortgage' &&
account.type !== 'loan',
)
.map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.type.replace('_', ' ')})
</option>
))}
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.type.replace('_', ' ')})
</option>
))}
</select>
</div>
</div>
@@ -502,6 +495,12 @@ export default function GetPaid({
</>
)}
{selectedAccountId && accountEnvelopes.length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-sm text-gray-700" style={{ borderColor: 'var(--color-cloud-blue)' }}>
No envelopes are linked to this account. Create envelopes for this account to distribute income here.
</div>
)}
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
type="button"
+36 -11
View File
@@ -99,6 +99,14 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{ value: 'loan', label: 'Loan', icon: '📋' },
];
const liabilityTypes: Account['type'][] = ['credit_card', 'mortgage', 'loan'];
const normalizeBalanceByType = (type: Account['type'], balance: number): number => {
if (liabilityTypes.includes(type)) {
return -Math.abs(balance || 0);
}
return balance || 0;
};
const envelopeColors = [
'bg-green-500', 'bg-blue-500', 'bg-purple-500', 'bg-orange-500',
'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500'
@@ -212,11 +220,13 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
return;
}
const normalizedBalance = normalizeBalanceByType(newAccount.type as Account['type'], newAccount.balance || 0);
const account: Account = {
id: `${newAccount.type}-${Date.now()}`,
name: newAccount.name,
type: newAccount.type as Account['type'],
balance,
balance: normalizedBalance,
institution: newAccount.institution,
accountNumber: newAccount.accountNumber,
color: newAccount.color || 'bg-blue-500',
@@ -233,7 +243,12 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
};
const handleAddEnvelope = () => {
if (!newEnvelope.name || !newEnvelope.accountId || newEnvelope.allocated === undefined) return;
const allocation = newEnvelope.allocated ?? 0;
if (!newEnvelope.name || !newEnvelope.accountId || allocation <= 0) {
alert('Please enter a budget amount greater than $0');
return;
}
// Validation: unique name
if (envelopes.some(env => env.name.toLowerCase() === newEnvelope.name!.toLowerCase())) {
@@ -244,7 +259,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
const envelope: Envelope = {
id: `env-${Date.now()}`,
name: newEnvelope.name,
allocated: newEnvelope.allocated,
allocated: allocation,
spent: 0,
color: newEnvelope.color || 'bg-green-500',
accountId: newEnvelope.accountId,
@@ -622,11 +637,21 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
return;
}
const standardDefaults: Record<string, number> = {
Rent: 1200,
Groceries: 500,
Utilities: 250,
Savings: 300,
Transport: 200,
Entertainment: 150,
Emergency: 200,
};
const standardNames = ['Rent', 'Groceries', 'Utilities', 'Savings', 'Transport', 'Entertainment', 'Emergency'];
const newStandardEnvelopes: Envelope[] = standardNames.map((name, idx) => ({
id: `env-standard-${Date.now()}-${idx}`,
name,
allocated: 0,
allocated: standardDefaults[name] ?? 100,
spent: 0,
color: envelopeColors[idx % envelopeColors.length],
accountId: targetAccountId,
@@ -714,13 +739,13 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
>
Cancel
</button>
<button
onClick={handleAddEnvelope}
disabled={!newEnvelope.name || !newEnvelope.accountId}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Add Envelope
</button>
<button
onClick={handleAddEnvelope}
disabled={!newEnvelope.name || !newEnvelope.accountId || !newEnvelope.allocated || newEnvelope.allocated <= 0}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Envelope
</button>
</div>
</div>
</div>