faber-finances/app/components/RecurringTransactionManager.vue

431 lines
15 KiB
Vue

<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h3 class="text-lg font-semibold">Recurring Transactions</h3>
</div>
<button
@click="$emit('add-transaction')"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded flex items-center">
<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 Recurring Transaction
</button>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white border-2 border-black shadow p-6">
<div class="text-center">
<p class="text-sm font-medium">Monthly Recurring Income</p>
<p class="text-2xl font-bold text-green-600">
{{ formatCurrency(monthlyRecurringIncome) }}
</p>
<p class="text-xs mt-1">{{ recurringIncomeCount }} income sources</p>
<div v-if="monthlyRecurringIncome === 0">
<button
@click="$emit('add-transaction')"
class="bg-blue-500 hover:bg-blue-600 text-white text-xs py-1 px-2 border-2 border-blue-700 mt-2">
Add Income
</button>
</div>
</div>
</div>
<div class="bg-white border-2 border-black shadow p-6">
<div class="text-center">
<p class="text-sm font-medium">Monthly Recurring Expenses</p>
<p class="text-2xl font-bold text-red-600">
{{ formatCurrency(monthlyRecurringExpenses) }}
</p>
<p class="text-xs mt-1">
{{ recurringExpenseCount }} expense sources
</p>
</div>
</div>
<div class="bg-white border-2 border-black shadow p-6">
<div class="text-center">
<p class="text-sm font-medium">Net Monthly Cash Flow</p>
<p class="text-2xl font-bold" :class="netCashFlowColor">
{{ formatCurrency(netMonthlyCashFlow) }}
</p>
</div>
</div>
</div>
<!-- Filters and Search -->
<div
class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex space-x-2">
<button
@click="activeFilter = 'all'"
:class="[
'px-3 py-2 text-sm font-medium border-2 transition-colors',
activeFilter === 'all'
? 'bg-blue-500 text-white border-blue-700'
: 'bg-white text-black border-black hover:bg-blue-50',
]">
All ({{ allRecurringTransactions.length }})
</button>
<button
@click="activeFilter = 'income'"
:class="[
'px-3 py-2 text-sm font-medium border-2 transition-colors',
activeFilter === 'income'
? 'bg-blue-500 text-white border-blue-700'
: 'bg-white text-black border-black hover:bg-blue-50',
]">
Income ({{ recurringIncomeTransactions.length }})
</button>
<button
@click="activeFilter = 'expenses'"
:class="[
'px-3 py-2 text-sm font-medium border-2 transition-colors',
activeFilter === 'expenses'
? 'bg-blue-500 text-white border-blue-700'
: 'bg-white text-black border-black hover:bg-blue-50',
]">
Expenses ({{ recurringExpenseTransactions.length }})
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search recurring transactions..."
class="w-full sm:w-64 px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- Recurring Transactions Table -->
<div class="bg-white border-2 border-black shadow overflow-hidden">
<div
class="flex justify-between items-center p-4 border-b-2 border-black">
<h4 class="font-semibold">Recurring Transactions</h4>
<span class="text-sm text-black">
{{ filteredTransactions.length }} transactions
</span>
</div>
<div v-if="filteredTransactions.length === 0" class="text-center py-8">
<svg
class="h-12 w-12 mx-auto mb-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<h3 class="text-lg font-medium mb-2">No Recurring Transactions</h3>
<p class="mb-4 text-gray-600">
Add recurring transactions to automate your cash flow projections
</p>
<button
@click="$emit('add-transaction')"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 border-2 border-blue-700">
Add Your First Recurring Transaction
</button>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full">
<thead class="bg-black text-white">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Description
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Category
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Frequency
</th>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
Amount
</th>
<th
class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider">
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
Active Period
</th>
<th
class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-black">
<tr
v-for="transaction in filteredTransactions"
:key="transaction.id || transaction._id"
class="hover:bg-blue-50 transition-colors cursor-pointer border-b border-black"
@click="editTransaction(transaction)">
<td class="px-6 py-4">
<div class="font-medium text-black">
{{ transaction.description }}
</div>
<div
v-if="transaction.notes"
class="text-xs text-black mt-1">
{{ transaction.notes }}
</div>
</td>
<td class="px-6 py-4">
<span
:class="getCategoryBadgeClass(transaction.category)"
class="inline-flex px-2 py-1 text-xs font-semibold border-2 border-black">
{{ transaction.category }}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<svg
class="h-4 w-4 mr-1 text-black"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="capitalize text-sm">
{{
transaction.frequency === "custom"
? `Every ${transaction.customMonths} months`
: transaction.frequency || "monthly"
}}
</span>
</div>
</td>
<td class="px-6 py-4 text-right">
<div
class="font-medium"
:class="
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
">
{{ formatCurrency(transaction.amount) }}
</div>
<!-- Show business split info if applicable -->
<div v-if="transaction.businessPercentage > 0" class="text-xs text-black mt-1 space-y-0.5">
<div>Personal: {{ formatCurrency(transaction.amount) }}</div>
<div class="text-blue-600">Business: {{ formatCurrency(transaction.businessAmount || 0) }} ({{ transaction.businessPercentage }}%)</div>
<div class="font-medium">Total: {{ formatCurrency(transaction.totalAmount || transaction.amount) }}</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span
:class="
transaction.isConfirmed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold border-2 border-black">
{{ transaction.isConfirmed ? "CONFIRMED" : "PENDING" }}
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-black">
{{ formatDate(transaction.date) }}
</div>
<div v-if="transaction.endDate" class="text-xs text-black">
Until {{ formatDate(transaction.endDate) }}
</div>
</td>
<td class="px-6 py-4 text-center">
<button
@click.stop="editTransaction(transaction)"
class="bg-white hover:bg-blue-50 text-black font-medium py-1 px-3 border-2 border-black text-sm">
Edit
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
transactions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"add-transaction",
"edit-transaction",
"delete-transaction",
]);
// State
const activeFilter = ref("all");
const searchQuery = ref("");
// Computed properties for filtering
const allRecurringTransactions = computed(() => {
return props.transactions
.filter((t) => t.type === "recurring")
.sort((a, b) => {
if (a.amount > 0 && b.amount <= 0) return -1;
if (a.amount <= 0 && b.amount > 0) return 1;
return Math.abs(b.amount) - Math.abs(a.amount);
});
});
const recurringIncomeTransactions = computed(() => {
return allRecurringTransactions.value.filter((t) => t.amount > 0);
});
const recurringExpenseTransactions = computed(() => {
return allRecurringTransactions.value.filter((t) => t.amount < 0);
});
const activeIncomeTransactions = computed(() => {
const now = new Date();
return recurringIncomeTransactions.value.filter((t) => {
const startDate = new Date(t.date);
const endDate = t.endDate ? new Date(t.endDate) : new Date("2099-12-31");
const isActive = now >= startDate && now <= endDate;
const isCommittedOrActual =
t.status === "actual" || t.status === "committed" || t.isConfirmed;
return isActive && isCommittedOrActual;
});
});
const filteredTransactions = computed(() => {
let filtered = allRecurringTransactions.value;
if (activeFilter.value === "income") {
filtered = recurringIncomeTransactions.value;
} else if (activeFilter.value === "expenses") {
filtered = recurringExpenseTransactions.value;
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.category.toLowerCase().includes(query) ||
t.notes?.toLowerCase().includes(query) ||
t.program?.toLowerCase().includes(query)
);
}
return filtered;
});
// Count computed properties
const recurringIncomeCount = computed(
() => recurringIncomeTransactions.value.length
);
const recurringExpenseCount = computed(
() => recurringExpenseTransactions.value.length
);
// Financial calculations
const monthlyRecurringIncome = computed(() => {
return activeIncomeTransactions.value.reduce((sum, t) => {
return sum + getMonthlyAmount(t);
}, 0);
});
const monthlyRecurringExpenses = computed(() => {
const now = new Date();
return allRecurringTransactions.value
.filter((t) => {
if (t.amount >= 0) return false;
const startDate = new Date(t.date);
const endDate = t.endDate ? new Date(t.endDate) : new Date("2099-12-31");
return now >= startDate && now <= endDate;
})
.reduce((sum, t) => {
return sum + Math.abs(getMonthlyAmount(t));
}, 0);
});
const netMonthlyCashFlow = computed(() => {
return monthlyRecurringIncome.value - monthlyRecurringExpenses.value;
});
const netCashFlowColor = computed(() => {
if (netMonthlyCashFlow.value > 0) return "text-green-600";
if (netMonthlyCashFlow.value < 0) return "text-red-600";
return "text-gray-600";
});
// Helper functions
const getMonthlyAmount = (transaction) => {
if (transaction.type !== "recurring") return 0;
const frequencyMultiplier = {
weekly: 4.33,
biweekly: 2.17,
monthly: 1,
quarterly: 0.33,
custom: transaction.customMonths ? 12 / transaction.customMonths : 1,
};
return (
transaction.amount *
(frequencyMultiplier[transaction.frequency || "monthly"] || 1) *
transaction.probability
);
};
const formatDate = (date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const getCategoryBadgeClass = (category) => {
const categoryColorMap = {
"expense: need": "bg-red-100 text-red-800",
"expense: want": "bg-orange-100 text-orange-800",
income: "bg-green-100 text-green-800",
};
return (
categoryColorMap[category.toLowerCase()] || "bg-gray-100 text-gray-800"
);
};
const editTransaction = (transaction) => {
emit("edit-transaction", transaction);
};
</script>