faber-finances/app/pages/index.vue
Jennie Robinson Faber cdbf0733c5 UI improvements and fixes
- Fixed brutalist styling across all components
- Removed notes field from transaction list display
- Added whitespace-nowrap to prevent description wrapping
- Updated modals with consistent border styling
- Improved form layouts and button styling
2025-08-23 11:57:21 +01:00

798 lines
27 KiB
Vue

<template>
<div class="min-h-screen bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Key Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Current Balance Card -->
<div class="bg-white border-4 border-black p-6">
<div class="text-center">
<p class="text-sm font-bold uppercase text-black">
AVAILABLE BALANCE
</p>
<p class="text-2xl font-bold text-black">
{{ formatCurrency(cashFlow?.startingBalance?.value || 0) }}
</p>
<button
@click="openBalanceModal"
class="mt-2 text-xs text-black hover:bg-black hover:text-white px-2 py-1 border-2 border-black font-bold uppercase">
UPDATE
</button>
</div>
</div>
<!-- Runway Card -->
<div class="bg-white border-4 border-black p-6">
<div class="text-center">
<p class="text-sm font-bold uppercase text-black">RUNWAY</p>
<p class="text-2xl font-bold text-black">
{{ runwayText }}
</p>
</div>
</div>
<!-- Core Expenses Card -->
<div class="bg-white border-4 border-black p-6">
<div class="text-center">
<p class="text-sm font-bold uppercase text-black">
CORE EXPENSES
</p>
<p class="text-2xl font-bold text-black">
{{ formatCurrency(monthlyCoreExpenses) }}
</p>
</div>
</div>
<!-- Income Card -->
<div class="bg-white border-4 border-black p-6">
<div class="text-center">
<p class="text-sm font-bold uppercase text-black">MONTHLY INCOME</p>
<p class="text-2xl font-bold text-black">
{{ formatCurrency(monthlyIncome) }}
</p>
</div>
</div>
</div>
<!-- Scenario Control Panel -->
<div class="bg-white border-4 border-black p-6 mb-8">
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div class="mb-4 sm:mb-0">
<h3 class="text-lg font-bold uppercase text-black">
FINANCIAL SCENARIO
</h3>
<p class="text-sm text-black">
<span
class="font-bold uppercase">
{{
scenario === "interest"
? "CURRENT (INTEREST)"
: "BANKRUPTCY (SURPLUS)"
}}
</span>
- {{ scenarioDescription }}
</p>
</div>
<div
class="flex flex-col sm:flex-row sm:items-center space-y-4 sm:space-y-0 sm:space-x-4">
<!-- Scenario Toggle -->
<div class="flex items-center space-x-3">
<label class="text-sm font-bold uppercase text-black">SCENARIO:</label>
<select
v-model="scenario"
@change="updateScenario"
class="px-3 py-2 border-2 border-black bg-white focus:outline-none focus:bg-black focus:text-white font-bold">
<option value="interest">INTEREST</option>
<option value="bankruptcy">BANKRUPTCY</option>
</select>
</div>
<!-- Dynamic Surplus Calculation Display (only show for bankruptcy scenario) -->
<div
v-if="scenario === 'bankruptcy'"
class="bg-white border-2 border-black px-3 py-2 text-sm">
<div class="font-bold uppercase text-black">
SURPLUS: {{ formatCurrency(calculatedSurplusPayment) }}/MO
</div>
<div class="text-xs text-black uppercase">
INC: {{ formatCurrency(householdNetIncome) }} | THR:
{{ formatCurrency(surplusThreshold) }} | AVL:
{{
formatCurrency(
Math.max(0, householdNetIncome - surplusThreshold)
)
}}
</div>
</div>
</div>
</div>
</div>
<!-- Transaction Modal -->
<TransactionModal
:is-open="isModalOpen"
:transaction="selectedTransaction"
@close="closeModal"
@save="saveTransaction"
@delete="handleDeleteTransactionFromModal" />
<!-- Delete Confirmation Modal -->
<div
v-if="showDeleteConfirmation"
class="fixed inset-0 bg-black bg-opacity-75 overflow-y-auto h-full w-full z-50">
<div
class="relative top-20 mx-auto p-5 border-4 border-black w-96 bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold uppercase text-black">
CONFIRM DELETE
</h3>
<button @click="cancelDelete" class="text-black hover:bg-black hover:text-white p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mb-4">
<p class="text-sm text-black">
Are you sure you want to delete this transaction? This action cannot be undone.
</p>
<div v-if="transactionToDelete" class="mt-3 p-3 bg-white border-2 border-black">
<p class="text-sm font-bold">{{ transactionToDelete.description }}</p>
<p class="text-sm text-black">
{{ formatCurrency(transactionToDelete.amount) }} - {{ transactionToDelete.category }}
</p>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
@click="cancelDelete"
class="px-4 py-2 text-sm font-bold text-black bg-white border-2 border-black hover:bg-black hover:text-white focus:outline-none uppercase">
CANCEL
</button>
<button
@click="confirmDelete"
class="px-4 py-2 text-sm font-bold text-white bg-black border-2 border-black hover:bg-white hover:text-black focus:outline-none uppercase">
DELETE
</button>
</div>
</div>
</div>
</div>
<!-- Balance Modal -->
<BalanceModal
:is-open="isBalanceModalOpen"
:current-balance="cashFlow?.currentBalance?.value || 0"
@close="closeBalanceModal"
@update="updateBalance" />
<!-- Projected Transactions (Next 13 Weeks) -->
<div class="bg-white border-4 border-black overflow-hidden">
<div
class="px-6 py-4 border-b-4 border-black flex justify-between items-center">
<h3 class="text-lg font-bold uppercase">
ALL TRANSACTIONS - NEXT 13 WEEKS
</h3>
<button
@click="handleAddTransaction"
class="bg-black text-white hover:bg-white hover:text-black font-bold py-2 px-4 border-2 border-black flex items-center uppercase">
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
ADD TRANSACTION
</button>
</div>
<div class="overflow-x-auto" v-if="projectedTransactions.length > 0">
<table class="w-full">
<thead class="bg-black text-white">
<tr>
<th
class="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider">
DATE
</th>
<th
class="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider">
DESCRIPTION
</th>
<th
class="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider">
CATEGORY
</th>
<th
class="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider">
AMOUNT
</th>
<th
class="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider">
BALANCE
</th>
<th
class="px-6 py-3 text-center text-xs font-bold uppercase tracking-wider">
ACTIONS
</th>
</tr>
</thead>
<tbody class="bg-white divide-y-2 divide-black">
<tr
v-for="transaction in projectedTransactions"
:key="`${transaction.id}-${transaction.date}`"
class="hover:bg-gray-100">
<td class="px-6 py-4 whitespace-nowrap text-sm text-black font-mono">
{{ formatDate(transaction.date) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<p class="text-sm font-bold text-black">
{{ transaction.description }}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-black font-mono">{{
transaction.category
}}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="text-black">
<p class="text-sm font-bold font-mono">
{{ formatCurrency(transaction.amount) }}
</p>
<!-- Show business split info if applicable -->
<div v-if="transaction.businessPercentage > 0" class="text-xs text-black space-y-0.5">
<div>PER: {{ formatCurrency(transaction.amount) }}</div>
<div>BUS: {{ formatCurrency(transaction.businessAmount || 0) }} ({{ transaction.businessPercentage }}%)</div>
<div class="font-bold">TTL: {{ formatCurrency(transaction.totalAmount || transaction.amount) }}</div>
</div>
<!-- Show original currency info if different and no business split -->
<p
v-else-if="
transaction.originalCurrency &&
transaction.originalCurrency !== 'CAD'
"
class="text-xs text-black">
({{
formatCurrency(
transaction.originalAmount,
transaction.originalCurrency
)
}})
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<p
class="text-sm font-bold font-mono"
:class="
transaction.runningBalance < 0
? 'bg-black text-white px-1'
: 'text-black'
">
{{ formatCurrency(transaction.runningBalance) }}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex justify-center space-x-2">
<button
@click="handleEditTransaction(transaction)"
class="text-black text-sm font-bold px-2 py-1 border-2 border-black hover:bg-black hover:text-white uppercase">
EDIT
</button>
<button
@click="handleDeleteTransaction(transaction.id)"
class="text-black text-sm font-bold px-2 py-1 border-2 border-black hover:bg-black hover:text-white uppercase">
DEL
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state when no transactions -->
<div v-else class="text-center py-12">
<svg
class="h-12 w-12 mx-auto mb-4 text-black"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="text-lg font-bold uppercase text-black mb-2">
NO TRANSACTIONS
</h3>
<p class="text-black mb-4">
Add your first transaction to start tracking your cash flow
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
// SEO
useSeoMeta({
title: "Faber Finances",
description: "$$$",
});
// Initialize cash flow composable
const cashFlow = useCashFlow();
// Reactive state
const isModalOpen = ref(false);
const selectedTransaction = ref(null);
const isBalanceModalOpen = ref(false);
const newBalance = ref(0);
const showDeleteConfirmation = ref(false);
const transactionToDelete = ref(null);
// Scenario state
const scenario = ref("interest"); // 'interest' or 'bankruptcy'
const interestTransactionId = ref("transaction_1755673351011"); // Store Interest transaction ID
// Surplus calculation constants
const surplusThreshold = 3318; // CAD threshold for 2-person household surplus calculation
// Calculate household net income from transactions (Personal account only)
const householdNetIncome = computed(() => {
if (!cashFlow?.transactions?.value) return 0;
return cashFlow.transactions.value
.filter((t) => t.amount > 0) // Only income transactions (positive amounts)
.reduce((total, transaction) => {
if (transaction.type === "recurring") {
// Calculate monthly income from recurring transactions
const frequencyMultiplier = {
weekly: 4.33, // ~4.33 weeks per month
biweekly: 2.165, // ~2.165 biweeks per month
monthly: 1,
quarterly: 1 / 3, // 1 quarter = 1/3 month
custom: 1 / (transaction.customMonths || 1),
};
return (
total +
transaction.amount *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1)
);
} else {
// For one-time transactions, don't include in monthly calculation
return total;
}
}, 0);
});
// Calculate dynamic surplus payment
const calculatedSurplusPayment = computed(() => {
if (householdNetIncome.value > surplusThreshold) {
const surplus = householdNetIncome.value - surplusThreshold;
return Math.round(surplus * 0.5); // 50% of surplus above threshold
}
return 0;
});
// Scenario descriptions
const scenarioDescription = computed(() => {
if (scenario.value === "interest") {
return "Paying $2,700/month in interest expenses";
} else {
return `Dynamic surplus based on income: ${formatCurrency(
calculatedSurplusPayment.value
)}/month`;
}
});
// Computed properties
const runwayText = computed(() => {
const days = cashFlow?.runwayDays?.value || 0;
if (days === Infinity) return "∞";
if (days > 365) return `${Math.round(days / 365)} years`;
if (days > 30) return `${Math.round(days / 30)} months`;
return `${Math.round(days)} days`;
});
const runwayColor = computed(() => {
const days = cashFlow?.runwayDays?.value || 0;
if (days < 30) return "text-red-600";
if (days < 90) return "text-orange-600";
return "text-green-600";
});
// Core Expenses (Need) - Monthly recurring expenses categorized as "Expense: Need"
const monthlyCoreExpenses = computed(() => {
if (!cashFlow?.filteredTransactions?.value) return 0;
const transactions = cashFlow.filteredTransactions.value;
let totalCoreExpenses = 0;
transactions.forEach((transaction) => {
// Only include "Expense: Need" category expenses (negative amounts)
if (transaction.amount >= 0 || transaction.category !== "Expense: Need")
return;
if (transaction.type === "recurring") {
// Convert recurring transactions to monthly amounts
const frequencyMultiplier = {
weekly: 4.33, // ~4.33 weeks per month
biweekly: 2.165, // ~2.165 biweeks per month
monthly: 1,
quarterly: 1 / 3, // 1 quarter = 1/3 month
custom: 1 / (transaction.customMonths || 1),
};
const monthlyAmount =
Math.abs(transaction.amount) *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1);
totalCoreExpenses += monthlyAmount * transaction.probability;
}
});
return totalCoreExpenses;
});
// Monthly Income - Monthly recurring income
const monthlyIncome = computed(() => {
if (!cashFlow?.filteredTransactions?.value) return 0;
const transactions = cashFlow.filteredTransactions.value;
let totalIncome = 0;
transactions.forEach((transaction) => {
// Only include income (positive amounts) - accept any income category
if (transaction.amount <= 0) return;
if (transaction.type === "recurring") {
// Convert recurring transactions to monthly amounts
const frequencyMultiplier = {
weekly: 4.33, // ~4.33 weeks per month
biweekly: 2.165, // ~2.165 biweeks per month
monthly: 1,
quarterly: 1 / 3, // 1 quarter = 1/3 month
custom: 1 / (transaction.customMonths || 1),
};
const monthlyAmount =
transaction.amount *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1);
totalIncome += monthlyAmount * transaction.probability;
}
});
return totalIncome;
});
const projectedTransactions = computed(() => {
if (!cashFlow?.filteredTransactions?.value) return [];
const transactions = cashFlow.filteredTransactions.value;
const projectedTransactions = [];
const today = new Date();
const endDate = new Date(today.getTime() + 13 * 7 * 24 * 60 * 60 * 1000); // 13 weeks from now
transactions.forEach((transaction) => {
if (transaction.type === "recurring") {
// Generate recurring transactions for the next 13 weeks
const startDate = new Date(
Math.max(today.getTime(), new Date(transaction.date).getTime())
);
const transactionEndDate = transaction.endDate
? new Date(transaction.endDate)
: endDate;
let currentDate = new Date(startDate);
while (currentDate <= endDate && currentDate <= transactionEndDate) {
projectedTransactions.push({
...transaction,
date: new Date(currentDate),
isProjected: true,
});
// Calculate next occurrence based on frequency
switch (transaction.frequency) {
case "weekly":
currentDate = new Date(
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000
);
break;
case "biweekly":
currentDate = new Date(
currentDate.getTime() + 14 * 24 * 60 * 60 * 1000
);
break;
case "monthly":
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
currentDate.getDate()
);
break;
case "quarterly":
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 3,
currentDate.getDate()
);
break;
case "custom":
const months = transaction.customMonths || 1;
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + months,
currentDate.getDate()
);
break;
default:
// Default to monthly
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
currentDate.getDate()
);
}
}
} else if (transaction.type === "one-time") {
// Include one-time transactions that fall within the next 13 weeks
const transactionDate = new Date(transaction.date);
if (transactionDate >= today && transactionDate <= endDate) {
projectedTransactions.push({
...transaction,
date: transactionDate,
isProjected: true,
});
}
}
});
// Sort by date (earliest first)
const sorted = projectedTransactions.sort(
(a, b) => new Date(a.date) - new Date(b.date)
);
// Calculate running balance for each transaction
// Start with account-specific starting balance (without projections)
let runningBalance = cashFlow?.startingBalance?.value || 0;
return sorted.map((transaction) => {
runningBalance += transaction.amount;
return {
...transaction,
runningBalance,
};
});
});
// Methods
const handleAddTransaction = () => {
selectedTransaction.value = null;
isModalOpen.value = true;
};
const handleEditTransaction = (transaction) => {
selectedTransaction.value = transaction;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedTransaction.value = null;
};
const saveTransaction = (transactionData) => {
if (!cashFlow) return;
const currentTransactions = cashFlow.transactions.value;
if (selectedTransaction.value) {
// Edit existing transaction
const index = currentTransactions.findIndex(
(t) => t.id === selectedTransaction.value.id
);
if (index !== -1) {
currentTransactions[index] = transactionData;
}
} else {
// Add new transaction
currentTransactions.push(transactionData);
}
cashFlow.updateTransactions(currentTransactions);
};
const openBalanceModal = () => {
newBalance.value = cashFlow?.currentBalance?.value || 0;
isBalanceModalOpen.value = true;
};
const closeBalanceModal = () => {
isBalanceModalOpen.value = false;
};
const updateBalance = (totalBalance) => {
if (cashFlow) {
cashFlow.setCurrentBalance(totalBalance);
}
closeBalanceModal();
};
// Delete transaction methods
const handleDeleteTransaction = (transactionId) => {
const transaction = cashFlow.transactions.value.find(t => t.id === transactionId);
if (transaction) {
transactionToDelete.value = transaction;
showDeleteConfirmation.value = true;
}
};
const handleDeleteTransactionFromModal = async (transactionId) => {
try {
await cashFlow.deleteTransaction(transactionId);
} catch (error) {
console.error('Failed to delete transaction:', error);
// You could add user notification here
}
};
const cancelDelete = () => {
showDeleteConfirmation.value = false;
transactionToDelete.value = null;
};
const confirmDelete = async () => {
if (transactionToDelete.value) {
try {
await cashFlow.deleteTransaction(transactionToDelete.value.id);
cancelDelete();
} catch (error) {
console.error('Failed to delete transaction:', error);
// You could add user notification here
}
}
};
const formatCurrency = (amount, currency = "CAD") => {
// For CAD amounts in the main dashboard, show no decimals for cleaner look
const decimals = currency === "CAD" ? 0 : 2;
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount || 0);
};
const formatDate = (date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(date));
};
// Scenario management methods (Personal account only)
const updateScenario = async () => {
if (!cashFlow?.transactions?.value) return;
// All transactions are now personal
const currentTransactions = [...cashFlow.transactions.value];
if (scenario.value === "bankruptcy") {
// Switch to bankruptcy scenario
// 1. Remove Interest transaction
const filteredTransactions = currentTransactions.filter(
(t) => !(t.description === "Interest" && t.amount === -2700)
);
// 2. Add dynamic Surplus Payment transaction (only if surplus > 0)
if (calculatedSurplusPayment.value > 0) {
const surplusTransaction = {
id: "bankruptcy_surplus_payment_transaction",
description: "Bankruptcy Surplus Payment",
amount: -calculatedSurplusPayment.value,
category: "Expense: Need",
type: "recurring",
frequency: "monthly",
date: new Date(),
probability: 1.0,
isConfirmed: true,
status: "committed",
notes: `Dynamic surplus: ${formatCurrency(
householdNetIncome.value
)} income - ${formatCurrency(
surplusThreshold
)} threshold = ${formatCurrency(
householdNetIncome.value - surplusThreshold
)} * 50%`,
};
filteredTransactions.push(surplusTransaction);
}
await cashFlow.updateTransactions(filteredTransactions);
} else {
// Switch to interest scenario
// 1. Remove Surplus Payment transaction
const filteredTransactions = currentTransactions.filter(
(t) => t.description !== "Bankruptcy Surplus Payment"
);
// 2. Add back Interest transaction
const interestTransaction = {
id: interestTransactionId.value,
description: "Interest",
amount: -2700,
category: "Expense: Need",
type: "recurring",
frequency: "monthly",
date: new Date("2025-08-29"),
probability: 1.0,
isConfirmed: true,
status: "committed",
notes: "",
};
filteredTransactions.push(interestTransaction);
await cashFlow.updateTransactions(filteredTransactions);
}
};
// Update surplus payment when income changes (watch for transaction changes, Personal only)
watch(
() => calculatedSurplusPayment.value,
async (newSurplus) => {
if (
scenario.value === "bankruptcy" &&
cashFlow?.transactions?.value
) {
const currentTransactions = [...cashFlow.transactions.value];
const surplusIndex = currentTransactions.findIndex(
(t) => t.description === "Bankruptcy Surplus Payment"
);
if (newSurplus > 0) {
if (surplusIndex !== -1) {
// Update existing surplus payment amount
currentTransactions[surplusIndex].amount = -newSurplus;
currentTransactions[
surplusIndex
].notes = `Dynamic surplus: ${formatCurrency(
householdNetIncome.value
)} income - ${formatCurrency(
surplusThreshold
)} threshold = ${formatCurrency(
householdNetIncome.value - surplusThreshold
)} * 50%`;
await cashFlow.updateTransactions(currentTransactions);
} else {
// Add new surplus payment if it doesn't exist
await updateScenario();
}
} else {
// Remove surplus payment if surplus becomes 0
if (surplusIndex !== -1) {
currentTransactions.splice(surplusIndex, 1);
await cashFlow.updateTransactions(currentTransactions);
}
}
}
}
);
// Initialize - balance will be loaded from localStorage by the composable
</script>