Init commit!
This commit is contained in:
commit
086d682592
34 changed files with 19249 additions and 0 deletions
431
app/components/RecurringTransactionManager.vue
Normal file
431
app/components/RecurringTransactionManager.vue
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue