Init commit!
This commit is contained in:
commit
086d682592
34 changed files with 19249 additions and 0 deletions
12
.env.example
Normal file
12
.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# MongoDB Database Connection
|
||||
MONGO_URI=mongodb://localhost:27017/faber-finances
|
||||
|
||||
# Wise API Personal Access Tokens
|
||||
# Get your tokens from: https://wise.com/help/articles/2958231/personal-access-tokens
|
||||
# Documentation: https://docs.wise.com/api-docs/guides/personal-tokens
|
||||
|
||||
WISE_API_KEY_JENNIE=your_jennie_wise_token_here
|
||||
WISE_API_KEY_HENRY=your_henry_wise_token_here
|
||||
|
||||
# Note: These tokens should have permission to read account balances
|
||||
# Copy this file to .env and fill in your actual tokens
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.aider*
|
||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
26
app/app.vue
Normal file
26
app/app.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<NuxtLink to="/" class="text-xl font-bold text-gray-900 font-mono">
|
||||
Faber Finances
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Main Content -->
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Global SEO
|
||||
useSeoMeta({
|
||||
titleTemplate: "%s - Faber Finances",
|
||||
description: "Personal finance and cash flow management system",
|
||||
});
|
||||
</script>
|
||||
433
app/components/BalanceModal.vue
Normal file
433
app/components/BalanceModal.vue
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<template>
|
||||
<div v-if="isOpen" class="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-10 mx-auto p-5 border-2 border-black w-full max-w-2xl shadow-lg bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-lg font-medium text-black">Update Available Balances</h3>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-black hover:text-red-600">
|
||||
<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>
|
||||
|
||||
<form @submit.prevent="updateBalances" class="space-y-6">
|
||||
<!-- Manual Entry Balances -->
|
||||
<div class="border-2 border-black p-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h4 class="text-md font-semibold text-black">Manual Entry</h4>
|
||||
<div v-if="lastManualUpdate" class="text-xs text-black mt-1">
|
||||
Last updated: {{ formatTimestamp(lastManualUpdate) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RBC -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
RBC Available Cash (CAD $)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="balances.rbc_cad"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00" />
|
||||
<p class="text-xs text-black mt-1">Including overdraft limit</p>
|
||||
</div>
|
||||
|
||||
<!-- TD -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
TD Available Cash (CAD $)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="balances.td_cad"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00" />
|
||||
<p class="text-xs text-black mt-1">Including overdraft limit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Millennium -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Millennium Balance (€)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="balances.millennium_eur"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Wise API Balances -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h4 class="text-md font-semibold text-gray-800">Wise Balances</h4>
|
||||
<div v-if="lastWiseFetch" class="text-xs text-gray-500 mt-1">
|
||||
Last fetched: {{ formatTimestamp(lastWiseFetch) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="fetchWiseBalances"
|
||||
:disabled="loading"
|
||||
class="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white text-sm font-medium py-1 px-3 border-2 border-green-700">
|
||||
{{ loading ? 'Fetching...' : 'Fetch from API' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Jennie Wise -->
|
||||
<div class="mb-4">
|
||||
<h5 class="text-sm font-medium text-black mb-2">Jennie Wise Balances</h5>
|
||||
<div v-if="wiseBalances.jennie && wiseBalances.jennie.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="balance in wiseBalances.jennie" :key="`jennie-${balance.currency}`" class="bg-white border-2 border-black p-3">
|
||||
<div class="text-xs text-black">{{ balance.currency }}</div>
|
||||
<div class="text-sm font-medium">{{ formatCurrency(balance.value.value, balance.currency) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-black italic">
|
||||
{{ loading ? 'Loading...' : 'No balances fetched yet' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Henry Wise -->
|
||||
<div>
|
||||
<h5 class="text-sm font-medium text-black mb-2">Henry Wise Balances</h5>
|
||||
<div v-if="wiseBalances.henry && wiseBalances.henry.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="balance in wiseBalances.henry" :key="`henry-${balance.currency}`" class="bg-white border-2 border-black p-3">
|
||||
<div class="text-xs text-black">{{ balance.currency }}</div>
|
||||
<div class="text-sm font-medium">{{ formatCurrency(balance.value.value, balance.currency) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-black italic">
|
||||
{{ loading ? 'Loading...' : 'No balances fetched yet' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total in CAD -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="bg-white border-2 border-black p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium text-black">Total Available Balance (CAD $)</span>
|
||||
<span class="text-lg font-bold text-blue-600">{{ formatCurrency(totalBalanceCAD, 'CAD') }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-black mt-1">All balances converted to CAD using current exchange rates</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-black bg-white border-2 border-black hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-black">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border-2 border-blue-800 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
Update Balances
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentBalance: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'update']);
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// Balance storage
|
||||
const balances = ref({
|
||||
rbc_cad: 0,
|
||||
td_cad: 0,
|
||||
millennium_eur: 0,
|
||||
});
|
||||
|
||||
const wiseBalances = ref({
|
||||
jennie: [],
|
||||
henry: []
|
||||
});
|
||||
|
||||
// Exchange rates - updated from Wise API for accurate conversions
|
||||
const exchangeRates = ref({
|
||||
CAD: 1.0,
|
||||
EUR: 1.45, // Will be updated from Wise API
|
||||
USD: 1.35, // Fallback rate
|
||||
GBP: 1.65 // Fallback rate
|
||||
});
|
||||
|
||||
// Fetch real-time exchange rates for balance conversions
|
||||
const fetchBalanceExchangeRates = async () => {
|
||||
try {
|
||||
// Fetch EUR to CAD rate (most common conversion)
|
||||
const eurResponse = await fetch('/api/wise/exchange-rates?source=EUR&target=CAD');
|
||||
if (eurResponse.ok) {
|
||||
const eurData = await eurResponse.json();
|
||||
exchangeRates.value.EUR = eurData.rate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update exchange rates for balances:', error);
|
||||
// Keep using fallback rates
|
||||
}
|
||||
};
|
||||
|
||||
// Timestamps for last updates
|
||||
const lastWiseFetch = ref(null);
|
||||
const lastManualUpdate = ref(null);
|
||||
|
||||
// Watch for modal open to load saved data
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
loadSavedBalances();
|
||||
fetchBalanceExchangeRates(); // Update exchange rates when modal opens
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved balances from MongoDB
|
||||
const loadSavedBalances = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/balances');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.accountBalances) {
|
||||
// Pre-fill with previous values
|
||||
balances.value = {
|
||||
rbc_cad: data.accountBalances.manual?.rbc_cad || 0,
|
||||
td_cad: data.accountBalances.manual?.td_cad || 0,
|
||||
millennium_eur: data.accountBalances.manual?.millennium_eur || 0,
|
||||
};
|
||||
wiseBalances.value = {
|
||||
jennie: data.accountBalances.wise?.jennie || [],
|
||||
henry: data.accountBalances.wise?.henry || []
|
||||
};
|
||||
|
||||
// Load timestamps
|
||||
lastWiseFetch.value = data.accountBalances.lastWiseFetch ? new Date(data.accountBalances.lastWiseFetch) : null;
|
||||
lastManualUpdate.value = data.accountBalances.lastManualUpdate ? new Date(data.accountBalances.lastManualUpdate) : null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load balances from MongoDB:', error);
|
||||
// Fallback to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('account_balances');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
balances.value = {
|
||||
rbc_cad: parsed.manual?.rbc_cad || 0,
|
||||
td_cad: parsed.manual?.td_cad || 0,
|
||||
millennium_eur: parsed.manual?.millennium_eur || 0,
|
||||
};
|
||||
wiseBalances.value = {
|
||||
jennie: parsed.wise?.jennie || [],
|
||||
henry: parsed.wise?.henry || []
|
||||
};
|
||||
lastWiseFetch.value = parsed.lastWiseFetch ? new Date(parsed.lastWiseFetch) : null;
|
||||
lastManualUpdate.value = parsed.lastManualUpdate ? new Date(parsed.lastManualUpdate) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save balances to MongoDB
|
||||
const saveBalances = async () => {
|
||||
try {
|
||||
// Update manual update timestamp
|
||||
lastManualUpdate.value = new Date();
|
||||
|
||||
const requestBody = {
|
||||
currentBalance: totalBalanceCAD.value,
|
||||
accountBalances: {
|
||||
manual: balances.value,
|
||||
wise: wiseBalances.value,
|
||||
lastWiseFetch: lastWiseFetch.value?.toISOString(),
|
||||
lastManualUpdate: lastManualUpdate.value.toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const response = await fetch('/api/balances', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save balances');
|
||||
}
|
||||
|
||||
console.log("✅ Balances saved to MongoDB");
|
||||
} catch (error) {
|
||||
console.error('Failed to save balances to MongoDB:', error);
|
||||
// Fallback to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const data = {
|
||||
manual: balances.value,
|
||||
wise: wiseBalances.value,
|
||||
lastWiseFetch: lastWiseFetch.value?.toISOString(),
|
||||
lastManualUpdate: lastManualUpdate.value.toISOString()
|
||||
};
|
||||
localStorage.setItem('account_balances', JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Wise balances via API
|
||||
const fetchWiseBalances = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// Fetch balances for both profiles
|
||||
const [jennieResponse, henryResponse] = await Promise.allSettled([
|
||||
fetch('/api/wise/jennie/balances'),
|
||||
fetch('/api/wise/henry/balances')
|
||||
]);
|
||||
|
||||
const newBalances = { jennie: [], henry: [] };
|
||||
|
||||
// Handle Jennie's balances
|
||||
if (jennieResponse.status === 'fulfilled' && jennieResponse.value.ok) {
|
||||
newBalances.jennie = await jennieResponse.value.json();
|
||||
} else {
|
||||
console.warn('Failed to fetch Jennie Wise balances:', jennieResponse.reason || jennieResponse.value.statusText);
|
||||
}
|
||||
|
||||
// Handle Henry's balances
|
||||
if (henryResponse.status === 'fulfilled' && henryResponse.value.ok) {
|
||||
newBalances.henry = await henryResponse.value.json();
|
||||
} else {
|
||||
console.warn('Failed to fetch Henry Wise balances:', henryResponse.reason || henryResponse.value.statusText);
|
||||
}
|
||||
|
||||
wiseBalances.value = newBalances;
|
||||
|
||||
// Update Wise fetch timestamp if successful
|
||||
if (newBalances.jennie.length > 0 || newBalances.henry.length > 0) {
|
||||
lastWiseFetch.value = new Date();
|
||||
}
|
||||
|
||||
// Show error only if both failed
|
||||
if (!newBalances.jennie.length && !newBalances.henry.length) {
|
||||
error.value = 'Failed to fetch Wise balances. Please check your API tokens or try again later.';
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error.value = 'Failed to fetch Wise balances. Please try again or enter manually.';
|
||||
console.error('Wise API error:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate total balance in CAD
|
||||
const totalBalanceCAD = computed(() => {
|
||||
let total = 0;
|
||||
|
||||
// Manual balances - Personal accounts
|
||||
total += balances.value.rbc_cad || 0;
|
||||
total += balances.value.td_cad || 0;
|
||||
total += (balances.value.millennium_eur || 0) * exchangeRates.value.EUR;
|
||||
|
||||
|
||||
// Wise balances
|
||||
[...(wiseBalances.value.jennie || []), ...(wiseBalances.value.henry || [])].forEach(balance => {
|
||||
const rate = exchangeRates.value[balance.currency] || 1;
|
||||
total += balance.value.value * rate;
|
||||
});
|
||||
|
||||
return total;
|
||||
});
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount, currency = 'CAD') => {
|
||||
const symbols = {
|
||||
CAD: '$',
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
GBP: '£'
|
||||
};
|
||||
|
||||
return new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount || 0);
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTimestamp = (date) => {
|
||||
if (!date) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Close modal
|
||||
const closeModal = () => {
|
||||
error.value = '';
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// Update balances
|
||||
const updateBalances = async () => {
|
||||
await saveBalances();
|
||||
emit('update', totalBalanceCAD.value);
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
440
app/components/CashFlowChart.vue
Normal file
440
app/components/CashFlowChart.vue
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Chart Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
Cash Flow Projection
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ selectedWeeks }}-week runway analysis
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<!-- Week Controls -->
|
||||
<div class="flex space-x-2">
|
||||
<UButton
|
||||
@click="selectedWeeks = 8"
|
||||
:variant="selectedWeeks === 8 ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
8 Weeks
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="selectedWeeks = 13"
|
||||
:variant="selectedWeeks === 13 ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
13 Weeks
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="selectedWeeks = 26"
|
||||
:variant="selectedWeeks === 26 ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
26 Weeks
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Balance Info -->
|
||||
<div class="flex space-x-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Starting Balance</p>
|
||||
<p class="font-semibold text-blue-600">
|
||||
{{ formatCurrency(startingBalance) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Projected Balance</p>
|
||||
<p class="font-semibold" :class="projectedBalanceColor">
|
||||
{{ formatCurrency(endingBalance) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Container -->
|
||||
<div class="relative h-64 bg-gray-50 rounded-lg p-4">
|
||||
<!-- Y-axis labels -->
|
||||
<div
|
||||
class="absolute left-4 top-4 bottom-8 w-20 flex flex-col justify-between text-xs text-gray-500">
|
||||
<span>{{ formatCurrency(maxValue) }}</span>
|
||||
<span>{{ formatCurrency(maxValue * 0.75) }}</span>
|
||||
<span>{{ formatCurrency(maxValue * 0.5) }}</span>
|
||||
<span>{{ formatCurrency(maxValue * 0.25) }}</span>
|
||||
<span>$0</span>
|
||||
</div>
|
||||
|
||||
<!-- Chart area -->
|
||||
<div class="ml-24 mr-16 h-full relative">
|
||||
<!-- Zero line -->
|
||||
<div
|
||||
class="absolute w-full border-t-2 border-red-300 border-dashed"
|
||||
:style="`bottom: ${getYPosition(0)}%`">
|
||||
<span
|
||||
class="absolute -right-12 -top-2 text-xs text-red-500 bg-white px-1"
|
||||
>$0</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Critical balance line -->
|
||||
<div
|
||||
v-if="criticalBalance > 0 && criticalBalance <= maxValue"
|
||||
class="absolute w-full border-t border-orange-400 border-dashed"
|
||||
:style="`bottom: ${getYPosition(criticalBalance)}%`">
|
||||
<span
|
||||
class="absolute -right-12 -top-2 text-xs text-orange-600 bg-white px-1"
|
||||
>Critical</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Cash flow line -->
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 400 240"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<!-- Grid lines -->
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid"
|
||||
width="30.77"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
d="M 30.77 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
stroke-width="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Area fill -->
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="cashFlowGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="0%"
|
||||
y2="100%">
|
||||
<stop
|
||||
offset="0%"
|
||||
style="stop-color: #3b82f6; stop-opacity: 0.3" />
|
||||
<stop
|
||||
offset="100%"
|
||||
style="stop-color: #3b82f6; stop-opacity: 0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path :d="cashFlowPath" fill="url(#cashFlowGradient)" stroke="none" />
|
||||
|
||||
<!-- Cash flow line -->
|
||||
<path
|
||||
:d="cashFlowPath"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
|
||||
<!-- Data points -->
|
||||
<circle
|
||||
v-for="(point, index) in chartPoints"
|
||||
:key="index"
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
r="4"
|
||||
:fill="getPointColor(point.balance)"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
class="cursor-pointer hover:r-6 transition-all"
|
||||
@mouseenter="showTooltip(index, $event)"
|
||||
@mouseleave="hideTooltip" />
|
||||
|
||||
<!-- X-axis labels inside SVG -->
|
||||
<text
|
||||
v-for="(point, index) in xAxisLabelPoints"
|
||||
:key="`label-${index}`"
|
||||
:x="point.x"
|
||||
:y="220"
|
||||
text-anchor="middle"
|
||||
fill="#6b7280"
|
||||
font-size="10"
|
||||
font-family="system-ui, -apple-system, sans-serif">
|
||||
{{ point.label }}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
v-if="tooltip.show"
|
||||
class="absolute z-10 bg-gray-900 text-white text-xs rounded py-2 px-3 pointer-events-none"
|
||||
:style="tooltipStyle">
|
||||
<div class="font-semibold">Week {{ tooltip.week }}</div>
|
||||
<div>Balance: {{ formatCurrency(tooltip.balance) }}</div>
|
||||
<div>Cash Flow: {{ formatCurrency(tooltip.netFlow) }}</div>
|
||||
<div>{{ formatDate(tooltip.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex items-center justify-center space-x-6 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-4 h-0.5 bg-blue-500"></div>
|
||||
<span class="text-gray-600">Cash Flow</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-4 h-0.5 border-t-2 border-red-300 border-dashed"></div>
|
||||
<span class="text-gray-600">Break Even</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-4 h-0.5 border-t border-orange-400 border-dashed"></div>
|
||||
<span class="text-gray-600">Critical Level</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div class="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-blue-800">Lowest Point</p>
|
||||
<p class="text-lg font-bold text-blue-600">
|
||||
{{ formatCurrency(lowestBalance) }}
|
||||
</p>
|
||||
<p class="text-xs text-blue-600">Week {{ lowestWeek }}</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-green-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-green-800">Total Inflow</p>
|
||||
<p class="text-lg font-bold text-green-600">
|
||||
{{ formatCurrency(totalInflow) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-red-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-red-800">Total Outflow</p>
|
||||
<p class="text-lg font-bold text-red-600">
|
||||
{{ formatCurrency(totalOutflow) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-purple-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-purple-800">Net Change</p>
|
||||
<p class="text-lg font-bold text-purple-600">
|
||||
{{ formatCurrency(netChange) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CashFlowProjection } from "~/types/cashflow";
|
||||
|
||||
interface Props {
|
||||
data: CashFlowProjection[];
|
||||
criticalBalance?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
criticalBalance: 50000,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"weeks-changed": [weeks: number];
|
||||
}>();
|
||||
|
||||
// Week selection
|
||||
const selectedWeeks = ref(13);
|
||||
|
||||
// Watch for changes and emit to parent
|
||||
watch(selectedWeeks, (newWeeks) => {
|
||||
emit("weeks-changed", newWeeks);
|
||||
});
|
||||
|
||||
// Reactive state for tooltip
|
||||
const tooltip = ref({
|
||||
show: false,
|
||||
week: 0,
|
||||
balance: 0,
|
||||
netFlow: 0,
|
||||
date: new Date(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const startingBalance = computed(() =>
|
||||
props.data.length > 0 ? props.data[0].balance : 0
|
||||
);
|
||||
|
||||
const endingBalance = computed(() =>
|
||||
props.data.length > 0 ? props.data[props.data.length - 1].projectedBalance : 0
|
||||
);
|
||||
|
||||
const maxValue = computed(() => {
|
||||
const balances = props.data.map((d) =>
|
||||
Math.max(d.balance, d.projectedBalance)
|
||||
);
|
||||
const max = Math.max(...balances, props.criticalBalance);
|
||||
return Math.ceil((max * 1.1) / 10000) * 10000;
|
||||
});
|
||||
|
||||
const minValue = computed(() => {
|
||||
const balances = props.data.map((d) =>
|
||||
Math.min(d.balance, d.projectedBalance)
|
||||
);
|
||||
return Math.min(...balances, 0);
|
||||
});
|
||||
|
||||
const chartPoints = computed(() => {
|
||||
if (props.data.length === 0) return [];
|
||||
|
||||
return props.data.map((point, index) => {
|
||||
const x =
|
||||
props.data.length === 1 ? 200 : (index / (props.data.length - 1)) * 400;
|
||||
const y = 200 - (getYPosition(point.projectedBalance) / 100) * 200;
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
balance: point.projectedBalance,
|
||||
date: point.date,
|
||||
netFlow: point.netFlow,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const cashFlowPath = computed(() => {
|
||||
if (chartPoints.value.length === 0) return "";
|
||||
|
||||
let path = `M ${chartPoints.value[0].x} ${chartPoints.value[0].y}`;
|
||||
|
||||
for (let i = 1; i < chartPoints.value.length; i++) {
|
||||
path += ` L ${chartPoints.value[i].x} ${chartPoints.value[i].y}`;
|
||||
}
|
||||
|
||||
const lastPoint = chartPoints.value[chartPoints.value.length - 1];
|
||||
const firstPoint = chartPoints.value[0];
|
||||
path += ` L ${lastPoint.x} 200 L ${firstPoint.x} 200 Z`;
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
const xAxisLabelPoints = computed(() => {
|
||||
if (props.data.length === 0) return [];
|
||||
|
||||
const maxLabels = 6;
|
||||
const step = Math.max(1, Math.floor(props.data.length / maxLabels));
|
||||
const labels = [];
|
||||
|
||||
for (let i = 0; i < props.data.length; i += step) {
|
||||
const point = props.data[i];
|
||||
const x =
|
||||
props.data.length === 1 ? 200 : (i / (props.data.length - 1)) * 400;
|
||||
|
||||
labels.push({
|
||||
label: point.date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
x: x,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.data.length > 1) {
|
||||
const lastIndex = props.data.length - 1;
|
||||
const lastIncluded = labels[labels.length - 1];
|
||||
const lastX = (lastIndex / (props.data.length - 1)) * 400;
|
||||
|
||||
if (Math.abs(lastIncluded.x - lastX) > 20) {
|
||||
labels.push({
|
||||
label: props.data[lastIndex].date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
x: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
});
|
||||
|
||||
const projectedBalanceColor = computed(() => {
|
||||
if (endingBalance.value < 0) return "text-red-600";
|
||||
if (endingBalance.value < props.criticalBalance) return "text-orange-600";
|
||||
if (endingBalance.value < startingBalance.value) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
});
|
||||
|
||||
const lowestBalance = computed(() =>
|
||||
Math.min(...props.data.map((d) => d.projectedBalance))
|
||||
);
|
||||
|
||||
const lowestWeek = computed(() => {
|
||||
const lowest = lowestBalance.value;
|
||||
const index = props.data.findIndex((d) => d.projectedBalance === lowest);
|
||||
return index + 1;
|
||||
});
|
||||
|
||||
const totalInflow = computed(() =>
|
||||
props.data.reduce((sum, d) => sum + d.inflow, 0)
|
||||
);
|
||||
|
||||
const totalOutflow = computed(() =>
|
||||
props.data.reduce((sum, d) => sum + Math.abs(d.outflow), 0)
|
||||
);
|
||||
|
||||
const netChange = computed(() => endingBalance.value - startingBalance.value);
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltip.value.x}px`,
|
||||
top: `${tooltip.value.y - 60}px`,
|
||||
transform: "translateX(-50%)",
|
||||
}));
|
||||
|
||||
// Helper functions
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const getYPosition = (value: number) => {
|
||||
const range = maxValue.value - minValue.value;
|
||||
return ((value - minValue.value) / range) * 100;
|
||||
};
|
||||
|
||||
const getPointColor = (balance: number) => {
|
||||
if (balance < 0) return "#dc2626";
|
||||
if (balance < props.criticalBalance) return "#ea580c";
|
||||
if (balance < props.criticalBalance * 1.5) return "#d97706";
|
||||
return "#059669";
|
||||
};
|
||||
|
||||
const showTooltip = (index: number, event: MouseEvent) => {
|
||||
const point = props.data[index];
|
||||
if (point) {
|
||||
tooltip.value = {
|
||||
show: true,
|
||||
week: index + 1,
|
||||
balance: point.projectedBalance,
|
||||
netFlow: point.netFlow,
|
||||
date: point.date,
|
||||
x: event.offsetX,
|
||||
y: event.offsetY,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltip.value.show = false;
|
||||
};
|
||||
</script>
|
||||
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>
|
||||
508
app/components/TransactionModal.vue
Normal file
508
app/components/TransactionModal.vue
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div
|
||||
class="relative top-20 mx-auto p-5 border-2 border-black w-96 shadow-lg bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-black">
|
||||
{{ isEditing ? "Edit Transaction" : "Add New Transaction" }}
|
||||
</h3>
|
||||
<button @click="closeModal" class="text-black hover:text-red-600">
|
||||
<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>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Description *
|
||||
</label>
|
||||
<input
|
||||
v-model="form.description"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter transaction description" />
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Amount *
|
||||
</label>
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
class="flex-1 px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter amount" />
|
||||
<select
|
||||
v-model="form.currency"
|
||||
class="px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="CAD">CAD $</option>
|
||||
<option value="EUR">EUR €</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.currency !== 'CAD' && form.amount"
|
||||
class="mt-1 space-y-1">
|
||||
<div class="text-sm text-black">
|
||||
{{ form.amount > 0 ? "+" : ""
|
||||
}}{{ formatCurrency(form.amount, form.currency) }} =
|
||||
{{ form.amount > 0 ? "+" : ""
|
||||
}}{{ formatCurrency(convertedAmount, "CAD") }} CAD
|
||||
<span v-if="exchangeRateLoading" class="text-blue-600 ml-2">
|
||||
⟳ Updating rate...
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
<span v-if="lastRateUpdate">
|
||||
Rate: {{ exchangeRates[form.currency].toFixed(5) }} ({{
|
||||
formatTimeAgo(lastRateUpdate)
|
||||
}})
|
||||
</span>
|
||||
<span v-if="exchangeRateError" class="text-orange-600 ml-2">
|
||||
⚠️ {{ exchangeRateError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-black mt-1">
|
||||
Positive for income, negative for expenses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.category"
|
||||
required
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select category</option>
|
||||
<option value="Expense: Need">Expense: Need</option>
|
||||
<option value="Expense: Want">Expense: Want</option>
|
||||
<option value="Income">Income</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Type *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
required
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="recurring">Recurring</option>
|
||||
<option value="one-time">One-time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Frequency (only for recurring) -->
|
||||
<div v-if="form.type === 'recurring'">
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Frequency *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.frequency"
|
||||
required
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Custom months (only for custom frequency) -->
|
||||
<div v-if="form.type === 'recurring' && form.frequency === 'custom'">
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Every X Months *
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.customMonths"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter number of months" />
|
||||
</div>
|
||||
|
||||
<!-- Start Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Start Date *
|
||||
</label>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- End Date (optional for recurring) -->
|
||||
<div v-if="form.type === 'recurring'">
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
End Date (optional)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.endDate"
|
||||
type="date"
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- Confirmed Status -->
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="form.isConfirmed"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-2 border-black" />
|
||||
<label class="ml-2 block text-sm text-black">
|
||||
Confirmed (check if this transaction is certain)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black mb-1">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Optional notes"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Business Percentage Split -->
|
||||
<div class="border-t pt-4">
|
||||
<label class="block text-sm font-medium text-black mb-3">
|
||||
Business/Personal Split
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<!-- Slider -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-black">Machine Magic %</span>
|
||||
<span class="text-sm font-medium text-blue-600">{{ form.businessPercentage }}%</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.businessPercentage"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
class="w-full h-2 bg-black appearance-none cursor-pointer slider" />
|
||||
</div>
|
||||
|
||||
<!-- Amount Breakdown -->
|
||||
<div class="bg-white border-2 border-black p-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-black">Machine Magic:</span>
|
||||
<span class="font-medium text-blue-600 ml-1">
|
||||
{{ formatCurrency(businessAmount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-black">Personal:</span>
|
||||
<span class="font-medium text-green-600 ml-1">
|
||||
{{ formatCurrency(personalAmount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-black mt-2">
|
||||
Only the personal portion ({{ formatCurrency(personalAmount) }}) affects your cash flow calculations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<!-- Delete button (only show when editing) -->
|
||||
<div>
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
@click="handleDelete"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 border-2 border-red-800 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500">
|
||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<!-- Cancel and Save buttons -->
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-black bg-white border-2 border-black hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-black">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border-2 border-blue-800 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{{ isEditing ? "Update" : "Add" }} Transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Get access to the cashFlow composable
|
||||
const cashFlow = useCashFlow();
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transaction: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "save", "delete"]);
|
||||
|
||||
const isEditing = computed(() => !!props.transaction);
|
||||
|
||||
const defaultForm = {
|
||||
description: "",
|
||||
amount: 0,
|
||||
currency: "CAD",
|
||||
category: "",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
customMonths: 1,
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
endDate: "",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
notes: "",
|
||||
businessPercentage: 0, // Default to 0% business (100% personal)
|
||||
};
|
||||
|
||||
const form = ref({ ...defaultForm });
|
||||
|
||||
// Exchange rates - will be updated from Wise API
|
||||
const exchangeRates = ref({
|
||||
CAD: 1.0,
|
||||
EUR: 1.45, // Will be updated from Wise API
|
||||
USD: 1.35, // Fallback rate
|
||||
GBP: 1.65, // Fallback rate
|
||||
});
|
||||
|
||||
const exchangeRateLoading = ref(false);
|
||||
const exchangeRateError = ref("");
|
||||
const lastRateUpdate = ref(null);
|
||||
|
||||
// Computed property for converted amount (always to CAD)
|
||||
const convertedAmount = computed(() => {
|
||||
if (!form.value.amount || form.value.currency === "CAD")
|
||||
return form.value.amount;
|
||||
return form.value.amount * exchangeRates.value[form.value.currency];
|
||||
});
|
||||
|
||||
// Computed properties for business/personal split
|
||||
const businessAmount = computed(() => {
|
||||
const totalAmount = convertedAmount.value || 0;
|
||||
return (totalAmount * form.value.businessPercentage) / 100;
|
||||
});
|
||||
|
||||
const personalAmount = computed(() => {
|
||||
const totalAmount = convertedAmount.value || 0;
|
||||
return totalAmount - businessAmount.value;
|
||||
});
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount, currency = "CAD") => {
|
||||
const symbols = {
|
||||
CAD: "$",
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
};
|
||||
|
||||
return new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount || 0);
|
||||
};
|
||||
|
||||
// Format time ago for exchange rate timestamps
|
||||
const formatTimeAgo = (date) => {
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return "today";
|
||||
};
|
||||
|
||||
// Fetch real-time exchange rates from Wise API
|
||||
const fetchExchangeRates = async (sourceCurrency) => {
|
||||
if (sourceCurrency === "CAD") return; // No need to fetch if already CAD
|
||||
|
||||
exchangeRateLoading.value = true;
|
||||
exchangeRateError.value = "";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/wise/exchange-rates?source=${sourceCurrency}&target=CAD`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
exchangeRates.value[sourceCurrency] = data.rate;
|
||||
lastRateUpdate.value = new Date(data.timestamp);
|
||||
|
||||
if (data.rateType === "fallback") {
|
||||
exchangeRateError.value = "Using fallback rate - Wise API unavailable";
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to fetch exchange rate");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Exchange rate fetch error:", error);
|
||||
exchangeRateError.value = "Failed to update exchange rate";
|
||||
} finally {
|
||||
exchangeRateLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (props.transaction) {
|
||||
form.value = {
|
||||
...defaultForm,
|
||||
...props.transaction,
|
||||
currency: props.transaction.originalCurrency || props.transaction.currency || "CAD",
|
||||
amount: props.transaction.originalAmount || props.transaction.totalAmount || props.transaction.amount,
|
||||
businessPercentage: props.transaction.businessPercentage || 0,
|
||||
date: new Date(props.transaction.date).toISOString().split("T")[0],
|
||||
endDate: props.transaction.endDate
|
||||
? new Date(props.transaction.endDate).toISOString().split("T")[0]
|
||||
: "",
|
||||
};
|
||||
} else {
|
||||
// For new transactions
|
||||
form.value = { ...defaultForm };
|
||||
}
|
||||
|
||||
// Fetch exchange rates when modal opens with non-CAD currency
|
||||
if (form.value.currency !== "CAD") {
|
||||
fetchExchangeRates(form.value.currency);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Watch for currency changes to fetch new exchange rates
|
||||
watch(
|
||||
() => form.value.currency,
|
||||
(newCurrency) => {
|
||||
if (newCurrency !== "CAD") {
|
||||
fetchExchangeRates(newCurrency);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const closeModal = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (props.transaction?.id) {
|
||||
emit("delete", props.transaction.id);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Convert amount to CAD for storage using real-time exchange rates
|
||||
const totalAmountInCAD =
|
||||
form.value.currency === "CAD"
|
||||
? form.value.amount
|
||||
: form.value.amount * exchangeRates.value[form.value.currency];
|
||||
|
||||
// Calculate personal portion (what affects personal cash flow)
|
||||
const personalAmountInCAD = personalAmount.value;
|
||||
|
||||
const transactionData = {
|
||||
...form.value,
|
||||
id: props.transaction?.id || `transaction_${Date.now()}`,
|
||||
amount: personalAmountInCAD, // Store personal amount only for cash flow calculations
|
||||
totalAmount: totalAmountInCAD, // Store total amount for reference
|
||||
businessAmount: businessAmount.value, // Store business amount for reference
|
||||
originalAmount: form.value.amount, // Store original amount for reference
|
||||
originalCurrency: form.value.currency, // Store original currency
|
||||
date: new Date(form.value.date),
|
||||
endDate: form.value.endDate ? new Date(form.value.endDate) : null,
|
||||
status: form.value.isConfirmed ? "committed" : "projected",
|
||||
};
|
||||
|
||||
emit("save", transactionData);
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom slider styling */
|
||||
.slider {
|
||||
background: linear-gradient(to right, #3b82f6 0%, #3b82f6 var(--percentage, 0%), #d1d5db var(--percentage, 0%), #d1d5db 100%);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
1062
app/composables/useCashFlow.ts
Normal file
1062
app/composables/useCashFlow.ts
Normal file
File diff suppressed because it is too large
Load diff
38
app/pages/basic.vue
Normal file
38
app/pages/basic.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<div class="p-8 bg-blue-100">
|
||||
<h1 class="text-4xl font-bold text-blue-800 mb-4">Basic Styling Test</h1>
|
||||
<p class="text-lg text-gray-700 mb-4">This tests if Tailwind CSS is working properly.</p>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow-lg mb-4">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-2">White Card</h2>
|
||||
<p class="text-gray-600">If you can see styling, Tailwind is working!</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="counter++"
|
||||
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">
|
||||
Click Count: {{ counter }}
|
||||
</button>
|
||||
|
||||
<div class="mt-4 p-4 bg-yellow-100 border-l-4 border-yellow-500">
|
||||
<p class="text-yellow-700">
|
||||
<strong>Test Results:</strong>
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-yellow-600 mt-2">
|
||||
<li>Background colors: {{ hasColors ? '✅ Working' : '❌ Not working' }}</li>
|
||||
<li>Padding/margins: {{ hasSpacing ? '✅ Working' : '❌ Not working' }}</li>
|
||||
<li>Typography: {{ hasTypography ? '✅ Working' : '❌ Not working' }}</li>
|
||||
<li>Reactivity: {{ counter > 0 ? '✅ Working' : '❌ Click button to test' }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const counter = ref(0);
|
||||
|
||||
// These would only be true if CSS is loading properly
|
||||
const hasColors = ref(true); // We'll assume true for now
|
||||
const hasSpacing = ref(true);
|
||||
const hasTypography = ref(true);
|
||||
</script>
|
||||
287
app/pages/cashflow.vue
Normal file
287
app/pages/cashflow.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Cash Flow Analysis</h1>
|
||||
<p class="text-gray-600 mt-2">Detailed cash flow projections and scenario analysis</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="mb-8">
|
||||
<UTabs :items="tabs" v-model="activeTab">
|
||||
<template #projections>
|
||||
<div class="space-y-6">
|
||||
<!-- Chart Controls -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex space-x-4">
|
||||
<UButton
|
||||
@click="projectionType = 'weekly'"
|
||||
:variant="projectionType === 'weekly' ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
Weekly View
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="projectionType = 'monthly'"
|
||||
:variant="projectionType === 'monthly' ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
Monthly View
|
||||
</UButton>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<UButton @click="exportData" variant="outline" size="sm">
|
||||
Export Data
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Chart -->
|
||||
<UCard>
|
||||
<CashFlowChart
|
||||
:data="projectionData"
|
||||
:critical-balance="criticalBalance"
|
||||
@weeks-changed="updateProjectionPeriod" />
|
||||
</UCard>
|
||||
|
||||
<!-- Summary Table -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Projection Summary</h3>
|
||||
</template>
|
||||
<UTable
|
||||
:rows="projectionSummary"
|
||||
:columns="summaryColumns" />
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #scenarios>
|
||||
<div class="space-y-6">
|
||||
<!-- Scenario Comparison -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Runway Scenarios</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="(scenario, key) in runwayScenarios"
|
||||
:key="key"
|
||||
class="text-center p-4 border rounded-lg">
|
||||
<h4 class="font-semibold capitalize mb-2">{{ key }}</h4>
|
||||
<p class="text-2xl font-bold" :class="getScenarioColor(scenario)">
|
||||
{{ formatRunwayDays(scenario) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ getScenarioDescription(key) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Risk Assessment -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Risk Assessment</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium">Current Risk Level</h4>
|
||||
<p class="text-sm text-gray-600">Based on realistic scenario</p>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="getRiskBadgeColor(cashFlow.riskLevel.value)"
|
||||
variant="subtle"
|
||||
size="lg">
|
||||
{{ cashFlow.riskLevel.value.toUpperCase() }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h5 class="font-medium">Recommendations:</h5>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li v-for="rec in riskRecommendations" :key="rec">{{ rec }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #transactions>
|
||||
<RecurringTransactionManager
|
||||
:transactions="cashFlow.transactions.value"
|
||||
@add-transaction="handleAddTransaction"
|
||||
@edit-transaction="handleEditTransaction" />
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Transaction } from "~/types/cashflow";
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: 'Cash Flow Analysis - Faber Finances',
|
||||
description: 'Detailed cash flow projections and scenario analysis'
|
||||
});
|
||||
|
||||
// Initialize cash flow composable
|
||||
const cashFlow = useCashFlow();
|
||||
|
||||
// Reactive state
|
||||
const activeTab = ref(0);
|
||||
const projectionType = ref<'weekly' | 'monthly'>('weekly');
|
||||
const projectionPeriod = ref(13);
|
||||
const criticalBalance = ref(50000);
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ slot: 'projections', label: 'Projections' },
|
||||
{ slot: 'scenarios', label: 'Scenarios' },
|
||||
{ slot: 'transactions', label: 'Transactions' }
|
||||
];
|
||||
|
||||
// Table columns
|
||||
const summaryColumns = [
|
||||
{ key: 'period', label: 'Period' },
|
||||
{ key: 'inflow', label: 'Inflow' },
|
||||
{ key: 'outflow', label: 'Outflow' },
|
||||
{ key: 'netFlow', label: 'Net Flow' },
|
||||
{ key: 'balance', label: 'Ending Balance' }
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
const projectionData = computed(() => {
|
||||
if (projectionType.value === 'weekly') {
|
||||
return cashFlow.generateWeeklyProjections(projectionPeriod.value);
|
||||
} else {
|
||||
return cashFlow.generateProjections(Math.ceil(projectionPeriod.value / 4.33));
|
||||
}
|
||||
});
|
||||
|
||||
const projectionSummary = computed(() => {
|
||||
return projectionData.value.map((projection, index) => ({
|
||||
period: projectionType.value === 'weekly'
|
||||
? `Week ${index + 1}`
|
||||
: projection.date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||
inflow: formatCurrency(projection.inflow),
|
||||
outflow: formatCurrency(projection.outflow),
|
||||
netFlow: formatCurrency(projection.netFlow),
|
||||
balance: formatCurrency(projection.projectedBalance)
|
||||
}));
|
||||
});
|
||||
|
||||
const runwayScenarios = computed(() => {
|
||||
return cashFlow.calculateRunwayScenarios();
|
||||
});
|
||||
|
||||
const riskRecommendations = computed(() => {
|
||||
const risk = cashFlow.riskLevel.value;
|
||||
const recommendations = [];
|
||||
|
||||
if (risk === 'critical') {
|
||||
recommendations.push('Immediate cash injection required');
|
||||
recommendations.push('Delay all non-essential expenses');
|
||||
recommendations.push('Accelerate revenue collection');
|
||||
recommendations.push('Consider emergency funding options');
|
||||
} else if (risk === 'high') {
|
||||
recommendations.push('Monitor cash flow weekly');
|
||||
recommendations.push('Reduce discretionary spending');
|
||||
recommendations.push('Focus on converting opportunities');
|
||||
recommendations.push('Prepare contingency plans');
|
||||
} else if (risk === 'medium') {
|
||||
recommendations.push('Review monthly expenses for optimization');
|
||||
recommendations.push('Build stronger revenue pipeline');
|
||||
recommendations.push('Consider increasing cash reserves');
|
||||
} else {
|
||||
recommendations.push('Maintain current financial discipline');
|
||||
recommendations.push('Consider strategic investments');
|
||||
recommendations.push('Build reserves for future opportunities');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const updateProjectionPeriod = (weeks: number) => {
|
||||
projectionPeriod.value = weeks;
|
||||
};
|
||||
|
||||
const handleAddTransaction = () => {
|
||||
console.log("Add transaction clicked");
|
||||
// TODO: Implement transaction modal
|
||||
};
|
||||
|
||||
const handleEditTransaction = (transaction: Transaction) => {
|
||||
console.log("Edit transaction:", transaction);
|
||||
// TODO: Implement transaction modal
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
const data = {
|
||||
projections: projectionData.value,
|
||||
scenarios: runwayScenarios.value,
|
||||
transactions: cashFlow.transactions.value,
|
||||
currentBalance: cashFlow.currentBalance.value,
|
||||
exportDate: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `cashflow-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatRunwayDays = (days: number) => {
|
||||
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 getScenarioColor = (days: number) => {
|
||||
if (days < 30) return "text-red-600";
|
||||
if (days < 90) return "text-orange-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
const getScenarioDescription = (scenario: string) => {
|
||||
switch (scenario) {
|
||||
case 'conservative': return 'Only confirmed transactions';
|
||||
case 'realistic': return 'Probability-weighted';
|
||||
case 'optimistic': return 'All transactions at full value';
|
||||
case 'cashOnly': return 'Current balance only';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskBadgeColor = (riskLevel: string) => {
|
||||
switch (riskLevel) {
|
||||
case "critical": return "red";
|
||||
case "high": return "orange";
|
||||
case "medium": return "yellow";
|
||||
default: return "green";
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize with some default balance if none exists
|
||||
onMounted(() => {
|
||||
if (cashFlow.currentBalance.value === 0) {
|
||||
cashFlow.setCurrentBalance(50000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
813
app/pages/index.vue
Normal file
813
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
<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-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">
|
||||
Available Balance
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ formatCurrency(cashFlow?.startingBalance?.value || 0) }}
|
||||
</p>
|
||||
<button
|
||||
@click="openBalanceModal"
|
||||
class="mt-2 text-xs text-blue-600 hover:text-blue-800 underline border-b border-blue-600">
|
||||
Update Balances
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runway Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">Runway</p>
|
||||
<p class="text-2xl font-bold" :class="runwayColor">
|
||||
{{ runwayText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Expenses Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">
|
||||
Core Expenses (Needs)
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
{{ formatCurrency(monthlyCoreExpenses) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Income Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">Monthly Income</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ formatCurrency(monthlyIncome) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario Control Panel -->
|
||||
<div class="bg-white border-2 border-black shadow-lg 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-semibold text-black">
|
||||
Financial Scenario
|
||||
</h3>
|
||||
<p class="text-sm text-black">
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="
|
||||
scenario === 'interest' ? 'text-red-600' : 'text-green-600'
|
||||
">
|
||||
{{
|
||||
scenario === "interest"
|
||||
? "🔴 Current (Interest Payments)"
|
||||
: "🟢 Bankruptcy (Surplus Payments)"
|
||||
}}
|
||||
</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-medium text-black">Scenario:</label>
|
||||
<select
|
||||
v-model="scenario"
|
||||
@change="updateScenario"
|
||||
class="px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="interest">Interest Payments</option>
|
||||
<option value="bankruptcy">Dynamic Surplus</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-medium text-black">
|
||||
Surplus: {{ formatCurrency(calculatedSurplusPayment) }}/month
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
Income: {{ formatCurrency(householdNetIncome) }} | Threshold:
|
||||
{{ formatCurrency(surplusThreshold) }} | Available:
|
||||
{{
|
||||
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-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div
|
||||
class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Confirm Delete
|
||||
</h3>
|
||||
<button @click="cancelDelete" class="text-gray-400 hover:text-gray-600">
|
||||
<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-gray-600">
|
||||
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-gray-50 rounded">
|
||||
<p class="text-sm font-medium">{{ transactionToDelete.description }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ 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-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500">
|
||||
Delete Transaction
|
||||
</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 rounded-lg shadow overflow-hidden">
|
||||
<div
|
||||
class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">
|
||||
All Transactions - Next 13 Weeks
|
||||
</h3>
|
||||
<button
|
||||
@click="handleAddTransaction"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 border-2 border-blue-800 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 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-medium uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<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-right text-xs font-medium uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
|
||||
Running Balance
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Notes
|
||||
</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 projectedTransactions"
|
||||
:key="`${transaction.id}-${transaction.date}`"
|
||||
class="hover:bg-blue-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-black">
|
||||
{{ formatDate(transaction.date) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-black">
|
||||
{{ transaction.description }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-black">{{
|
||||
transaction.category
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div
|
||||
:class="
|
||||
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
|
||||
">
|
||||
<p class="text-sm font-medium">
|
||||
{{ formatCurrency(transaction.amount) }}
|
||||
</p>
|
||||
<!-- Show business split info if applicable -->
|
||||
<div v-if="transaction.businessPercentage > 0" class="text-xs text-gray-500 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>
|
||||
<!-- Show original currency info if different and no business split -->
|
||||
<p
|
||||
v-else-if="
|
||||
transaction.originalCurrency &&
|
||||
transaction.originalCurrency !== 'CAD'
|
||||
"
|
||||
class="text-xs text-gray-500">
|
||||
({{
|
||||
formatCurrency(
|
||||
transaction.originalAmount,
|
||||
transaction.originalCurrency
|
||||
)
|
||||
}})
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<p
|
||||
class="text-sm font-bold"
|
||||
:class="
|
||||
transaction.runningBalance < 0
|
||||
? 'text-red-600'
|
||||
: 'text-black'
|
||||
">
|
||||
{{ formatCurrency(transaction.runningBalance) }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-left">
|
||||
<p class="text-sm text-black max-w-xs truncate" :title="transaction.notes">
|
||||
{{ transaction.notes || '-' }}
|
||||
</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-blue-600 hover:text-blue-800 text-sm font-medium px-2 py-1 border border-blue-600 hover:bg-blue-50">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
@click="handleDeleteTransaction(transaction.id)"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium px-2 py-1 border border-red-600 hover:bg-red-50">
|
||||
Delete
|
||||
</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-gray-400"
|
||||
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-medium text-gray-900 mb-2">
|
||||
No transactions yet
|
||||
</h3>
|
||||
<p class="text-gray-600 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>
|
||||
285
app/pages/migrate.vue
Normal file
285
app/pages/migrate.vue
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Data Migration</h1>
|
||||
<p class="text-gray-600 mt-2">Migrate your data from localStorage to MongoDB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<!-- Status -->
|
||||
<div v-if="status" class="mb-6 p-4 rounded" :class="statusClass">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<!-- Migration Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">1. Auto-Migration from localStorage</h3>
|
||||
<button
|
||||
@click="migrateFromLocalStorage"
|
||||
:disabled="loading"
|
||||
class="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded">
|
||||
{{ loading ? 'Migrating...' : 'Migrate from localStorage' }}
|
||||
</button>
|
||||
<p class="text-sm text-gray-600 mt-2">
|
||||
This will automatically detect and migrate any existing localStorage data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Manual Import Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">2. Manual Data Import</h3>
|
||||
|
||||
<!-- Current Balance -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Balance (CAD $)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="manualData.balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00" />
|
||||
</div>
|
||||
|
||||
<!-- Transactions JSON -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transactions JSON
|
||||
</label>
|
||||
<textarea
|
||||
v-model="manualData.transactionsJson"
|
||||
rows="10"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Paste your transactions JSON array here"></textarea>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Paste the JSON array of your transactions (the data you provided earlier)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="migrateManualData"
|
||||
:disabled="loading"
|
||||
class="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded">
|
||||
{{ loading ? 'Importing...' : 'Import Manual Data' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Data Display -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">3. Current Data Status</h3>
|
||||
<button
|
||||
@click="loadCurrentData"
|
||||
:disabled="loading"
|
||||
class="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded mb-4">
|
||||
{{ loading ? 'Loading...' : 'Refresh Data Status' }}
|
||||
</button>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<p><strong>Current Balance:</strong> {{ formatCurrency(currentData.balance) }}</p>
|
||||
<p><strong>Transactions:</strong> {{ currentData.transactions.length }} items</p>
|
||||
<div v-if="currentData.transactions.length > 0" class="mt-2">
|
||||
<p class="text-sm font-medium">Sample transactions:</p>
|
||||
<ul class="text-sm text-gray-600 mt-1">
|
||||
<li v-for="transaction in currentData.transactions.slice(0, 3)" :key="transaction.id">
|
||||
{{ transaction.description }}: {{ formatCurrency(transaction.amount) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="pt-4 border-t">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">
|
||||
Back to Dashboard
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Page metadata
|
||||
useSeoMeta({
|
||||
title: 'Data Migration - Faber Finances',
|
||||
description: 'Migrate your financial data'
|
||||
});
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const status = ref('');
|
||||
const statusClass = ref('');
|
||||
|
||||
const manualData = ref({
|
||||
balance: 0,
|
||||
transactionsJson: ''
|
||||
});
|
||||
|
||||
const currentData = ref({
|
||||
balance: 0,
|
||||
transactions: []
|
||||
});
|
||||
|
||||
// Load current data status
|
||||
const loadCurrentData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Load balance
|
||||
const balanceResponse = await fetch('/api/balances');
|
||||
if (balanceResponse.ok) {
|
||||
const balanceData = await balanceResponse.json();
|
||||
currentData.value.balance = balanceData.currentBalance || 0;
|
||||
}
|
||||
|
||||
// Load transactions
|
||||
const transactionsResponse = await fetch('/api/transactions');
|
||||
if (transactionsResponse.ok) {
|
||||
const transactionsData = await transactionsResponse.json();
|
||||
currentData.value.transactions = transactionsData;
|
||||
}
|
||||
|
||||
setStatus('Data loaded successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to load current data:', error);
|
||||
setStatus('Failed to load current data: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate from localStorage
|
||||
const migrateFromLocalStorage = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Window not available');
|
||||
}
|
||||
|
||||
const savedBalance = localStorage.getItem('cashflow_balance');
|
||||
const savedTransactions = localStorage.getItem('cashflow_transactions');
|
||||
|
||||
if (!savedBalance && !savedTransactions) {
|
||||
setStatus('No localStorage data found to migrate', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationData = {
|
||||
balance: savedBalance ? parseFloat(savedBalance) : 0,
|
||||
transactions: savedTransactions ? JSON.parse(savedTransactions) : []
|
||||
};
|
||||
|
||||
const response = await fetch('/api/migrate/localStorage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(migrationData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Migration failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setStatus(
|
||||
`Migration successful! Migrated ${result.transactionsCount} transactions and balance of ${formatCurrency(result.balance)}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// Clear localStorage after successful migration
|
||||
localStorage.removeItem('cashflow_balance');
|
||||
localStorage.removeItem('cashflow_transactions');
|
||||
localStorage.removeItem('account_balances');
|
||||
|
||||
// Refresh current data
|
||||
await loadCurrentData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
setStatus('Migration failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Import manual data
|
||||
const migrateManualData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
let transactions = [];
|
||||
|
||||
if (manualData.value.transactionsJson.trim()) {
|
||||
try {
|
||||
transactions = JSON.parse(manualData.value.transactionsJson);
|
||||
if (!Array.isArray(transactions)) {
|
||||
throw new Error('Transactions must be an array');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const migrationData = {
|
||||
balance: manualData.value.balance || 0,
|
||||
transactions: transactions
|
||||
};
|
||||
|
||||
const response = await fetch('/api/migrate/localStorage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(migrationData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Import failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setStatus(
|
||||
`Import successful! Imported ${result.transactionsCount} transactions and balance of ${formatCurrency(result.balance)}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// Clear form
|
||||
manualData.value = { balance: 0, transactionsJson: '' };
|
||||
|
||||
// Refresh current data
|
||||
await loadCurrentData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
setStatus('Import failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
const setStatus = (message, type) => {
|
||||
status.value = message;
|
||||
statusClass.value = type === 'success' ? 'bg-green-100 text-green-800' :
|
||||
type === 'warning' ? 'bg-yellow-100 text-yellow-800' :
|
||||
type === 'error' ? 'bg-red-100 text-red-800' :
|
||||
'bg-blue-100 text-blue-800';
|
||||
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => {
|
||||
status.value = '';
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: 'CAD'
|
||||
}).format(amount || 0);
|
||||
};
|
||||
|
||||
// Load current data on mount
|
||||
onMounted(() => {
|
||||
loadCurrentData();
|
||||
});
|
||||
</script>
|
||||
24
app/pages/minimal.vue
Normal file
24
app/pages/minimal.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<div class="p-8">
|
||||
<h1 class="text-3xl font-bold text-blue-600">Minimal Test Page</h1>
|
||||
<p class="mt-4">If you can see this, basic routing and Vue are working!</p>
|
||||
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded">
|
||||
<p>✅ Vue template rendering: Working</p>
|
||||
<p>✅ Tailwind CSS: Working</p>
|
||||
<p>✅ Nuxt routing: Working</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="counter++"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
Counter: {{ counter }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const counter = ref(0);
|
||||
</script>
|
||||
96
app/pages/simple.vue
Normal file
96
app/pages/simple.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div class="p-8">
|
||||
<h1 class="text-2xl font-bold mb-4">Simple Test Page</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p>This is a simple test page to verify basic functionality.</p>
|
||||
|
||||
<div class="p-4 bg-blue-50 border rounded">
|
||||
<p>
|
||||
Testing composable:
|
||||
{{ composableWorking ? "Working" : "Not working" }}
|
||||
</p>
|
||||
<p>Current balance: {{ currentBalance }}</p>
|
||||
<p>Transactions count: {{ transactionCount }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-x-2">
|
||||
<button
|
||||
@click="setBalance"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
Set Balance to $50,000
|
||||
</button>
|
||||
<button
|
||||
@click="addSampleTransaction"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
|
||||
Add Sample Transaction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-4 bg-red-50 border border-red-200 rounded">
|
||||
<p class="text-red-700">Error: {{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const errorMessage = ref("");
|
||||
const composableWorking = ref(false);
|
||||
const currentBalance = ref(0);
|
||||
const transactionCount = ref(0);
|
||||
|
||||
let cashFlow = null;
|
||||
|
||||
try {
|
||||
cashFlow = useCashFlow();
|
||||
composableWorking.value = true;
|
||||
|
||||
// Watch for changes
|
||||
watchEffect(() => {
|
||||
if (cashFlow) {
|
||||
currentBalance.value = cashFlow.currentBalance.value;
|
||||
transactionCount.value = cashFlow.transactions.value.length;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage.value = `Failed to initialize cashFlow: ${error.message}`;
|
||||
console.error("CashFlow error:", error);
|
||||
}
|
||||
|
||||
const setBalance = () => {
|
||||
try {
|
||||
if (cashFlow) {
|
||||
cashFlow.setCurrentBalance(50000);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = `Failed to set balance: ${error.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
const addSampleTransaction = () => {
|
||||
try {
|
||||
if (cashFlow) {
|
||||
const sampleTransaction = {
|
||||
id: `sample-${Date.now()}`,
|
||||
date: new Date(),
|
||||
description: "Sample Transaction",
|
||||
amount: 1000,
|
||||
category: "Income",
|
||||
type: "one-time",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
};
|
||||
|
||||
cashFlow.updateTransactions([
|
||||
...cashFlow.transactions.value,
|
||||
sampleTransaction,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = `Failed to add transaction: ${error.message}`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
206
app/pages/test.vue
Normal file
206
app/pages/test.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">Cash Flow Test Page</h1>
|
||||
|
||||
<!-- Test Results -->
|
||||
<UCard class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Component Test Results</h2>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="test in testResults"
|
||||
:key="test.name"
|
||||
class="flex items-center justify-between">
|
||||
<span>{{ test.name }}</span>
|
||||
<UBadge :color="test.passed ? 'green' : 'red'">
|
||||
{{ test.passed ? "PASS" : "FAIL" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Cash Flow Summary -->
|
||||
<UCard class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Cash Flow Summary</h2>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Current Balance</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ formatCurrency(cashFlow.currentBalance.value) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Transactions</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ cashFlow.transactions.value.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Monthly Burn</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ formatCurrency(cashFlow.monthlyBurnRate.value) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Runway Days</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ Math.round(cashFlow.runwayDays.value) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Mini Chart Test -->
|
||||
<UCard class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Chart Test (8 weeks)</h2>
|
||||
</template>
|
||||
<CashFlowChart :data="testProjections" :critical-balance="30000" />
|
||||
</UCard>
|
||||
|
||||
<!-- Mini Transaction Manager Test -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Transaction Manager Test</h2>
|
||||
</template>
|
||||
<RecurringTransactionManager
|
||||
:transactions="cashFlow.transactions.value"
|
||||
@add-transaction="handleAddTransaction"
|
||||
@edit-transaction="handleEditTransaction" />
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Transaction } from "~/types/cashflow";
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Test Page",
|
||||
description: "Testing cash flow functionality",
|
||||
});
|
||||
|
||||
// Initialize cash flow
|
||||
const cashFlow = useCashFlow();
|
||||
|
||||
// Test data
|
||||
const testTransactions: Transaction[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
date: new Date("2024-01-01"),
|
||||
description: "Test Salary",
|
||||
amount: 10000,
|
||||
category: "Income",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
status: "committed",
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
date: new Date("2024-01-01"),
|
||||
description: "Test Rent",
|
||||
amount: -2000,
|
||||
category: "Expense: Need",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
status: "committed",
|
||||
},
|
||||
];
|
||||
|
||||
// Test results
|
||||
const testResults = computed(() => {
|
||||
const results = [];
|
||||
|
||||
// Test 1: Composable loads
|
||||
try {
|
||||
results.push({
|
||||
name: "useCashFlow composable loads",
|
||||
passed: !!cashFlow,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "useCashFlow composable loads",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test 2: Transaction updates work
|
||||
try {
|
||||
results.push({
|
||||
name: "Transaction updates work",
|
||||
passed: typeof cashFlow.updateTransactions === "function",
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "Transaction updates work",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test 3: Projections generate
|
||||
try {
|
||||
const projections = cashFlow.generateWeeklyProjections(4);
|
||||
results.push({
|
||||
name: "Weekly projections generate",
|
||||
passed: Array.isArray(projections) && projections.length > 0,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "Weekly projections generate",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test 4: Runway calculation works
|
||||
try {
|
||||
const runway = cashFlow.runwayDays.value;
|
||||
results.push({
|
||||
name: "Runway calculation works",
|
||||
passed: typeof runway === "number",
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "Runway calculation works",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
const testProjections = computed(() => {
|
||||
return cashFlow.generateWeeklyProjections(8);
|
||||
});
|
||||
|
||||
const handleAddTransaction = () => {
|
||||
console.log("Add transaction test");
|
||||
};
|
||||
|
||||
const handleEditTransaction = (transaction: Transaction) => {
|
||||
console.log("Edit transaction test:", transaction.description);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Initialize test data
|
||||
onMounted(() => {
|
||||
cashFlow.setCurrentBalance(75000);
|
||||
cashFlow.updateTransactions(testTransactions);
|
||||
});
|
||||
</script>
|
||||
92
app/types/cashflow.ts
Normal file
92
app/types/cashflow.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export interface Transaction {
|
||||
id?: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
amount: number; // Personal portion (after business split) in CAD
|
||||
totalAmount?: number; // Total original amount in CAD
|
||||
businessAmount?: number; // Business portion in CAD
|
||||
businessPercentage?: number; // Percentage attributed to business (0-100)
|
||||
originalAmount?: number; // Original amount before conversion
|
||||
originalCurrency?: string; // Original currency (CAD, EUR, etc.)
|
||||
currency?: string; // Current/display currency (for backward compatibility)
|
||||
category: string;
|
||||
type: "recurring" | "one-time";
|
||||
program?: string;
|
||||
status?: "actual" | "committed" | "projected";
|
||||
fundType?: "restricted" | "unrestricted";
|
||||
frequency?: "weekly" | "biweekly" | "monthly" | "quarterly" | "custom";
|
||||
customMonths?: number;
|
||||
endDate?: Date;
|
||||
probability: number;
|
||||
isConfirmed: boolean;
|
||||
isTest?: boolean;
|
||||
notes?: string;
|
||||
projectedDate?: Date;
|
||||
runningBalance?: number;
|
||||
}
|
||||
|
||||
export interface RevenueOpportunity {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
source: string;
|
||||
amount: number;
|
||||
probability: number;
|
||||
targetDate: Date;
|
||||
stage: string;
|
||||
notes?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface CashFlowProjection {
|
||||
date: Date;
|
||||
balance: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
netFlow: number;
|
||||
projectedBalance: number;
|
||||
}
|
||||
|
||||
export interface RunwayScenarios {
|
||||
conservative: number;
|
||||
realistic: number;
|
||||
optimistic: number;
|
||||
cashOnly: number;
|
||||
}
|
||||
|
||||
export interface CashPosition {
|
||||
balance: number;
|
||||
totalInflow: number;
|
||||
totalOutflow: number;
|
||||
netFlow: number;
|
||||
}
|
||||
|
||||
export interface TransactionForecast {
|
||||
month: Date;
|
||||
recurring: Transaction[];
|
||||
oneTime: Transaction[];
|
||||
pipeline: Transaction[];
|
||||
totalInflow: number;
|
||||
totalOutflow: number;
|
||||
runningBalance: number;
|
||||
}
|
||||
|
||||
export interface CombinedCashFlow {
|
||||
historical: CashFlowProjection[];
|
||||
projected: CashFlowProjection[];
|
||||
combined: CashFlowProjection[];
|
||||
currentBalanceIndex: number;
|
||||
}
|
||||
|
||||
export interface AffordabilityCheck {
|
||||
canAfford: boolean;
|
||||
currentAvailable?: number;
|
||||
newBalance?: number;
|
||||
currentRunwayWeeks?: number;
|
||||
newRunwayWeeks?: number;
|
||||
riskLevel?: string;
|
||||
recommendation?: string;
|
||||
reason?: string;
|
||||
availableAmount?: number;
|
||||
shortfall?: number;
|
||||
}
|
||||
6
nuxt.config.ts
Normal file
6
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxtjs/tailwindcss']
|
||||
})
|
||||
13837
package-lock.json
generated
Normal file
13837
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "nuxt-app",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"mongodb": "^6.18.0",
|
||||
"nuxt": "^4.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-Agent: *
|
||||
Disallow:
|
||||
42
server/api/balances/index.get.js
Normal file
42
server/api/balances/index.get.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const collection = await getCollection('balances')
|
||||
const balances = await collection.findOne({ type: 'current' })
|
||||
|
||||
if (!balances) {
|
||||
// Return default balance structure if none exists
|
||||
return {
|
||||
currentBalance: 0,
|
||||
accountBalances: {
|
||||
manual: {
|
||||
rbc_cad: 0,
|
||||
td_cad: 0,
|
||||
millennium_eur: 0
|
||||
},
|
||||
wise: {
|
||||
jennie: [],
|
||||
henry: []
|
||||
},
|
||||
lastWiseFetch: null,
|
||||
lastManualUpdate: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure timestamp fields exist
|
||||
if (balances.accountBalances && !balances.accountBalances.lastWiseFetch && !balances.accountBalances.lastManualUpdate) {
|
||||
balances.accountBalances.lastWiseFetch = null;
|
||||
balances.accountBalances.lastManualUpdate = null;
|
||||
}
|
||||
|
||||
return balances
|
||||
} catch (error) {
|
||||
console.error('Error fetching balances:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch balances'
|
||||
})
|
||||
}
|
||||
})
|
||||
43
server/api/balances/index.put.js
Normal file
43
server/api/balances/index.put.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const collection = await getCollection('balances')
|
||||
|
||||
// Get existing balance document to preserve timestamp data
|
||||
const existingBalance = await collection.findOne({ type: 'current' })
|
||||
|
||||
const balanceData = {
|
||||
type: 'current',
|
||||
currentBalance: body.currentBalance,
|
||||
accountBalances: {
|
||||
manual: body.accountBalances?.manual || {},
|
||||
wise: body.accountBalances?.wise || { jennie: [], henry: [] },
|
||||
// Preserve existing timestamps if not explicitly provided
|
||||
lastWiseFetch: body.accountBalances?.lastWiseFetch || existingBalance?.accountBalances?.lastWiseFetch || null,
|
||||
lastManualUpdate: body.accountBalances?.lastManualUpdate || existingBalance?.accountBalances?.lastManualUpdate || null
|
||||
},
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
// Upsert the balance record
|
||||
const result = await collection.replaceOne(
|
||||
{ type: 'current' },
|
||||
balanceData,
|
||||
{ upsert: true }
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modifiedCount: result.modifiedCount,
|
||||
upsertedCount: result.upsertedCount
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating balances:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update balances'
|
||||
})
|
||||
}
|
||||
})
|
||||
69
server/api/migrate/localStorage.post.js
Normal file
69
server/api/migrate/localStorage.post.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const data = await readBody(event)
|
||||
|
||||
// Migrate transactions
|
||||
if (data.transactions && data.transactions.length > 0) {
|
||||
const transactionsCollection = await getCollection('transactions')
|
||||
|
||||
// Clear existing transactions
|
||||
await transactionsCollection.deleteMany({})
|
||||
|
||||
// Convert and insert transactions
|
||||
const mongoTransactions = data.transactions.map(transaction => ({
|
||||
...transaction,
|
||||
_id: transaction.id,
|
||||
id: undefined,
|
||||
date: new Date(transaction.date),
|
||||
endDate: transaction.endDate ? new Date(transaction.endDate) : null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}))
|
||||
|
||||
await transactionsCollection.insertMany(mongoTransactions)
|
||||
}
|
||||
|
||||
// Migrate balance
|
||||
if (data.balance !== undefined) {
|
||||
const balancesCollection = await getCollection('balances')
|
||||
|
||||
const balanceData = {
|
||||
type: 'current',
|
||||
currentBalance: data.balance,
|
||||
accountBalances: data.accountBalances || {
|
||||
manual: {
|
||||
rbc_cad: 0,
|
||||
td_cad: 0,
|
||||
millennium_eur: 0
|
||||
},
|
||||
wise: {
|
||||
jennie: [],
|
||||
henry: []
|
||||
}
|
||||
},
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
await balancesCollection.replaceOne(
|
||||
{ type: 'current' },
|
||||
balanceData,
|
||||
{ upsert: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Data migrated successfully',
|
||||
transactionsCount: data.transactions?.length || 0,
|
||||
balance: data.balance || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error migrating localStorage data:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to migrate data'
|
||||
})
|
||||
}
|
||||
})
|
||||
29
server/api/transactions/[id].delete.js
Normal file
29
server/api/transactions/[id].delete.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const collection = await getCollection('transactions')
|
||||
|
||||
// Delete the transaction
|
||||
const result = await collection.deleteOne({ _id: id })
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Transaction not found'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: result.deletedCount
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting transaction:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to delete transaction'
|
||||
})
|
||||
}
|
||||
})
|
||||
40
server/api/transactions/[id].put.js
Normal file
40
server/api/transactions/[id].put.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const collection = await getCollection('transactions')
|
||||
|
||||
// Update document
|
||||
const updateData = {
|
||||
...body,
|
||||
_id: id,
|
||||
id: undefined,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
const result = await collection.replaceOne(
|
||||
{ _id: id },
|
||||
updateData
|
||||
)
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Transaction not found'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modifiedCount: result.modifiedCount
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating transaction:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update transaction'
|
||||
})
|
||||
}
|
||||
})
|
||||
42
server/api/transactions/bulk.put.js
Normal file
42
server/api/transactions/bulk.put.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const transactions = await readBody(event)
|
||||
const collection = await getCollection('transactions')
|
||||
|
||||
// Clear existing transactions and insert new ones
|
||||
await collection.deleteMany({})
|
||||
|
||||
if (transactions.length > 0) {
|
||||
// Convert transactions for MongoDB
|
||||
const mongoTransactions = transactions.map(transaction => ({
|
||||
...transaction,
|
||||
_id: transaction.id,
|
||||
id: undefined,
|
||||
date: new Date(transaction.date),
|
||||
endDate: transaction.endDate ? new Date(transaction.endDate) : null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}))
|
||||
|
||||
const result = await collection.insertMany(mongoTransactions)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
insertedCount: result.insertedCount
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
insertedCount: 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating transactions:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to bulk update transactions'
|
||||
})
|
||||
}
|
||||
})
|
||||
23
server/api/transactions/index.get.js
Normal file
23
server/api/transactions/index.get.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const collection = await getCollection('transactions')
|
||||
const transactions = await collection.find({}).toArray()
|
||||
|
||||
// Convert MongoDB _id to id for frontend compatibility
|
||||
const formattedTransactions = transactions.map(transaction => ({
|
||||
...transaction,
|
||||
id: transaction._id.toString(),
|
||||
_id: undefined
|
||||
}))
|
||||
|
||||
return formattedTransactions
|
||||
} catch (error) {
|
||||
console.error('Error fetching transactions:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch transactions'
|
||||
})
|
||||
}
|
||||
})
|
||||
30
server/api/transactions/index.post.js
Normal file
30
server/api/transactions/index.post.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { getCollection } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const collection = await getCollection('transactions')
|
||||
|
||||
// Convert id to _id for MongoDB
|
||||
const transactionData = {
|
||||
...body,
|
||||
_id: body.id,
|
||||
id: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
const result = await collection.insertOne(transactionData)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: result.insertedId.toString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating transaction:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create transaction'
|
||||
})
|
||||
}
|
||||
})
|
||||
70
server/api/wise/[profile]/balances.get.js
Normal file
70
server/api/wise/[profile]/balances.get.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const profile = getRouterParam(event, 'profile') // 'jennie' or 'henry'
|
||||
|
||||
// Get API token from environment variables
|
||||
const apiToken = profile === 'jennie'
|
||||
? process.env.WISE_API_KEY_JENNIE
|
||||
: process.env.WISE_API_KEY_HENRY
|
||||
|
||||
if (!apiToken) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Wise API token not configured for ${profile}`
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// First, get the profile ID
|
||||
const profileResponse = await fetch('https://api.wise.com/v1/profiles', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
throw new Error(`Profile fetch failed: ${profileResponse.statusText}`)
|
||||
}
|
||||
|
||||
const profiles = await profileResponse.json()
|
||||
const personalProfile = profiles.find(p => p.type === 'personal')
|
||||
|
||||
if (!personalProfile) {
|
||||
throw new Error('Personal profile not found')
|
||||
}
|
||||
|
||||
// Get balances for the profile
|
||||
const balancesResponse = await fetch(`https://api.wise.com/v4/profiles/${personalProfile.id}/balances?types=STANDARD`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!balancesResponse.ok) {
|
||||
throw new Error(`Balances fetch failed: ${balancesResponse.statusText}`)
|
||||
}
|
||||
|
||||
const balances = await balancesResponse.json()
|
||||
|
||||
// Filter and format the balances - include all currencies
|
||||
const formattedBalances = balances
|
||||
.filter(balance => balance.amount) // Only filter out balances without amount data
|
||||
.map(balance => ({
|
||||
currency: balance.amount.currency,
|
||||
value: {
|
||||
value: balance.amount.value
|
||||
}
|
||||
}))
|
||||
|
||||
return formattedBalances
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Wise API error for ${profile}:`, error)
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to fetch Wise balances: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
80
server/api/wise/exchange-rates.get.js
Normal file
80
server/api/wise/exchange-rates.get.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { source = 'EUR', target = 'CAD' } = query
|
||||
|
||||
// Get API token from environment variables (use Jennie's token by default)
|
||||
const apiToken = process.env.WISE_API_KEY_JENNIE
|
||||
|
||||
if (!apiToken) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Wise API token not configured'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// First get the profile ID
|
||||
const profileResponse = await fetch('https://api.wise.com/v1/profiles', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
throw new Error(`Profile fetch failed: ${profileResponse.statusText}`)
|
||||
}
|
||||
|
||||
const profiles = await profileResponse.json()
|
||||
const personalProfile = profiles.find(p => p.type === 'personal')
|
||||
|
||||
if (!personalProfile) {
|
||||
throw new Error('Personal profile not found')
|
||||
}
|
||||
|
||||
// Get exchange rates
|
||||
const ratesResponse = await fetch(`https://api.wise.com/v1/rates?source=${source}&target=${target}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!ratesResponse.ok) {
|
||||
throw new Error(`Exchange rates fetch failed: ${ratesResponse.statusText}`)
|
||||
}
|
||||
|
||||
const rates = await ratesResponse.json()
|
||||
|
||||
// Return the rate and timestamp
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
rate: rates[0]?.rate || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
rateType: rates[0]?.type || 'unknown'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Wise exchange rate API error:`, error)
|
||||
|
||||
// Fallback to hardcoded rate if API fails
|
||||
const fallbackRates = {
|
||||
'EUR-CAD': 1.45,
|
||||
'USD-CAD': 1.35,
|
||||
'GBP-CAD': 1.65
|
||||
}
|
||||
|
||||
const fallbackKey = `${source}-${target}`
|
||||
const fallbackRate = fallbackRates[fallbackKey] || 1.0
|
||||
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
rate: fallbackRate,
|
||||
timestamp: new Date().toISOString(),
|
||||
rateType: 'fallback',
|
||||
error: 'Using fallback rate due to API error'
|
||||
}
|
||||
}
|
||||
})
|
||||
41
server/utils/db.js
Normal file
41
server/utils/db.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { MongoClient } from 'mongodb'
|
||||
|
||||
let client = null
|
||||
let db = null
|
||||
|
||||
export async function connectToDatabase() {
|
||||
if (db) {
|
||||
return db
|
||||
}
|
||||
|
||||
const uri = process.env.MONGO_URI
|
||||
if (!uri) {
|
||||
throw new Error('MONGO_URI environment variable is not set')
|
||||
}
|
||||
|
||||
try {
|
||||
client = new MongoClient(uri)
|
||||
await client.connect()
|
||||
db = client.db('faber-finances')
|
||||
console.log('Connected to MongoDB')
|
||||
return db
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to MongoDB:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCollection(name) {
|
||||
const database = await connectToDatabase()
|
||||
return database.collection(name)
|
||||
}
|
||||
|
||||
// Close connection (useful for cleanup)
|
||||
export async function closeDatabaseConnection() {
|
||||
if (client) {
|
||||
await client.close()
|
||||
client = null
|
||||
db = null
|
||||
console.log('Disconnected from MongoDB')
|
||||
}
|
||||
}
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue