faber-finances/app/components/TransactionModal.vue

508 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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