431 lines
15 KiB
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>
|