faber-finances/app/components/TransactionModal.vue
Jennie Robinson Faber cdbf0733c5 UI improvements and fixes
- Fixed brutalist styling across all components
- Removed notes field from transaction list display
- Added whitespace-nowrap to prevent description wrapping
- Updated modals with consistent border styling
- Improved form layouts and button styling
2025-08-23 11:57:21 +01:00

504 lines
17 KiB
Vue

<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black bg-opacity-75 overflow-y-auto h-full w-full z-50">
<div
class="relative top-20 mx-auto p-5 border-4 border-black w-96 bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold uppercase text-black">
{{ isEditing ? "EDIT TRANSACTION" : "ADD TRANSACTION" }}
</h3>
<button @click="closeModal" class="text-black hover:bg-black hover:text-white p-1">
<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-bold uppercase 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 bg-white focus:outline-none focus:bg-black focus:text-white font-mono"
placeholder="Enter description" />
</div>
<!-- Amount -->
<div>
<label class="block text-sm font-bold uppercase 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 bg-white focus:outline-none focus:bg-black focus:text-white font-mono"
placeholder="0.00" />
<select
v-model="form.currency"
class="px-3 py-2 border-2 border-black bg-white focus:outline-none focus:bg-black focus:text-white font-bold">
<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 font-mono">
{{ form.amount > 0 ? "+" : ""
}}{{ formatCurrency(form.amount, form.currency) }} =
{{ form.amount > 0 ? "+" : ""
}}{{ formatCurrency(convertedAmount, "CAD") }}
<span v-if="exchangeRateLoading" class="text-black ml-2">
UPDATING...
</span>
</div>
<div class="text-xs text-black font-mono">
<span v-if="lastRateUpdate">
RATE: {{ exchangeRates[form.currency].toFixed(5) }} ({{
formatTimeAgo(lastRateUpdate)
}})
</span>
<span v-if="exchangeRateError" class="text-black ml-2 font-bold">
{{ exchangeRateError }}
</span>
</div>
</div>
<p class="text-xs text-black mt-1 uppercase">
POSITIVE = INCOME, NEGATIVE = EXPENSE
</p>
</div>
<!-- Category -->
<div>
<label class="block text-sm font-bold uppercase text-black mb-1">
CATEGORY *
</label>
<select
v-model="form.category"
required
class="w-full px-3 py-2 border-2 border-black bg-white focus:outline-none focus:bg-black focus:text-white font-bold">
<option value="">SELECT</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-bold uppercase text-black mb-1">
TYPE *
</label>
<select
v-model="form.type"
required
class="w-full px-3 py-2 border-2 border-black bg-white focus:outline-none focus:bg-black focus:text-white font-bold">
<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-bold uppercase text-black mb-1">
FREQUENCY *
</label>
<select
v-model="form.frequency"
required
class="w-full px-3 py-2 border-2 border-black bg-white focus:outline-none focus:bg-black focus:text-white font-bold">
<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-bold uppercase 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 bg-white focus:outline-none focus:bg-black focus:text-white font-mono"
placeholder="1" />
</div>
<!-- Start Date -->
<div>
<label class="block text-sm font-bold uppercase 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 bg-white focus:outline-none focus:bg-black focus:text-white font-mono" />
</div>
<!-- End Date (optional for recurring) -->
<div v-if="form.type === 'recurring'">
<label class="block text-sm font-bold uppercase 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 bg-white focus:outline-none focus:bg-black focus:text-white font-mono" />
</div>
<!-- Confirmed Status -->
<div class="flex items-center">
<input
v-model="form.isConfirmed"
type="checkbox"
class="h-4 w-4 border-2 border-black accent-black" />
<label class="ml-2 block text-sm text-black uppercase">
CONFIRMED
</label>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-bold uppercase text-black mb-1">
NOTES
</label>
<textarea
v-model="form.notes"
rows="2"
class="w-full px-3 py-2 border-2 border-black bg-white focus:outline-none focus:bg-black focus:text-white font-mono"
placeholder="Optional"></textarea>
</div>
<!-- Business Percentage Split -->
<div class="border-t-2 border-black pt-4">
<label class="block text-sm font-bold uppercase 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 uppercase">MACHINE MAGIC %</span>
<span class="text-sm font-bold text-black">{{ 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 uppercase">BUSINESS:</span>
<span class="font-bold text-black ml-1">
{{ formatCurrency(businessAmount) }}
</span>
</div>
<div>
<span class="text-black uppercase">PERSONAL:</span>
<span class="font-bold text-black ml-1">
{{ formatCurrency(personalAmount) }}
</span>
</div>
</div>
<div class="text-xs text-black mt-2 uppercase">
PERSONAL PORTION AFFECTS CASH FLOW
</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-bold text-white bg-black border-2 border-black hover:bg-white hover:text-black focus:outline-none uppercase">
<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-bold text-black bg-white border-2 border-black hover:bg-black hover:text-white focus:outline-none uppercase">
CANCEL
</button>
<button
type="submit"
class="px-4 py-2 text-sm font-bold text-white bg-black border-2 border-black hover:bg-white hover:text-black focus:outline-none uppercase">
{{ isEditing ? "UPDATE" : "ADD" }}
</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: #000;
}
.slider::-webkit-slider-thumb {
appearance: none;
height: 20px;
width: 20px;
background: #fff;
cursor: pointer;
border: 2px solid black;
}
.slider::-moz-range-thumb {
height: 20px;
width: 20px;
background: #fff;
cursor: pointer;
border: 2px solid black;
}
</style>