Init commit!

This commit is contained in:
Jennie Robinson Faber 2025-08-22 18:36:16 +01:00
commit 086d682592
34 changed files with 19249 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>