Init commit!
This commit is contained in:
commit
086d682592
34 changed files with 19249 additions and 0 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue