Init commit!
This commit is contained in:
commit
086d682592
34 changed files with 19249 additions and 0 deletions
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