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