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