508 lines
17 KiB
Vue
508 lines
17 KiB
Vue
<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>
|