Init commit!
This commit is contained in:
commit
086d682592
34 changed files with 19249 additions and 0 deletions
38
app/pages/basic.vue
Normal file
38
app/pages/basic.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<div class="p-8 bg-blue-100">
|
||||
<h1 class="text-4xl font-bold text-blue-800 mb-4">Basic Styling Test</h1>
|
||||
<p class="text-lg text-gray-700 mb-4">This tests if Tailwind CSS is working properly.</p>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow-lg mb-4">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-2">White Card</h2>
|
||||
<p class="text-gray-600">If you can see styling, Tailwind is working!</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="counter++"
|
||||
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">
|
||||
Click Count: {{ counter }}
|
||||
</button>
|
||||
|
||||
<div class="mt-4 p-4 bg-yellow-100 border-l-4 border-yellow-500">
|
||||
<p class="text-yellow-700">
|
||||
<strong>Test Results:</strong>
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-yellow-600 mt-2">
|
||||
<li>Background colors: {{ hasColors ? '✅ Working' : '❌ Not working' }}</li>
|
||||
<li>Padding/margins: {{ hasSpacing ? '✅ Working' : '❌ Not working' }}</li>
|
||||
<li>Typography: {{ hasTypography ? '✅ Working' : '❌ Not working' }}</li>
|
||||
<li>Reactivity: {{ counter > 0 ? '✅ Working' : '❌ Click button to test' }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const counter = ref(0);
|
||||
|
||||
// These would only be true if CSS is loading properly
|
||||
const hasColors = ref(true); // We'll assume true for now
|
||||
const hasSpacing = ref(true);
|
||||
const hasTypography = ref(true);
|
||||
</script>
|
||||
287
app/pages/cashflow.vue
Normal file
287
app/pages/cashflow.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Cash Flow Analysis</h1>
|
||||
<p class="text-gray-600 mt-2">Detailed cash flow projections and scenario analysis</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="mb-8">
|
||||
<UTabs :items="tabs" v-model="activeTab">
|
||||
<template #projections>
|
||||
<div class="space-y-6">
|
||||
<!-- Chart Controls -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex space-x-4">
|
||||
<UButton
|
||||
@click="projectionType = 'weekly'"
|
||||
:variant="projectionType === 'weekly' ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
Weekly View
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="projectionType = 'monthly'"
|
||||
:variant="projectionType === 'monthly' ? 'solid' : 'outline'"
|
||||
size="sm">
|
||||
Monthly View
|
||||
</UButton>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<UButton @click="exportData" variant="outline" size="sm">
|
||||
Export Data
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Chart -->
|
||||
<UCard>
|
||||
<CashFlowChart
|
||||
:data="projectionData"
|
||||
:critical-balance="criticalBalance"
|
||||
@weeks-changed="updateProjectionPeriod" />
|
||||
</UCard>
|
||||
|
||||
<!-- Summary Table -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Projection Summary</h3>
|
||||
</template>
|
||||
<UTable
|
||||
:rows="projectionSummary"
|
||||
:columns="summaryColumns" />
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #scenarios>
|
||||
<div class="space-y-6">
|
||||
<!-- Scenario Comparison -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Runway Scenarios</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="(scenario, key) in runwayScenarios"
|
||||
:key="key"
|
||||
class="text-center p-4 border rounded-lg">
|
||||
<h4 class="font-semibold capitalize mb-2">{{ key }}</h4>
|
||||
<p class="text-2xl font-bold" :class="getScenarioColor(scenario)">
|
||||
{{ formatRunwayDays(scenario) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ getScenarioDescription(key) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Risk Assessment -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Risk Assessment</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium">Current Risk Level</h4>
|
||||
<p class="text-sm text-gray-600">Based on realistic scenario</p>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="getRiskBadgeColor(cashFlow.riskLevel.value)"
|
||||
variant="subtle"
|
||||
size="lg">
|
||||
{{ cashFlow.riskLevel.value.toUpperCase() }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h5 class="font-medium">Recommendations:</h5>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li v-for="rec in riskRecommendations" :key="rec">{{ rec }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #transactions>
|
||||
<RecurringTransactionManager
|
||||
:transactions="cashFlow.transactions.value"
|
||||
@add-transaction="handleAddTransaction"
|
||||
@edit-transaction="handleEditTransaction" />
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Transaction } from "~/types/cashflow";
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: 'Cash Flow Analysis - Faber Finances',
|
||||
description: 'Detailed cash flow projections and scenario analysis'
|
||||
});
|
||||
|
||||
// Initialize cash flow composable
|
||||
const cashFlow = useCashFlow();
|
||||
|
||||
// Reactive state
|
||||
const activeTab = ref(0);
|
||||
const projectionType = ref<'weekly' | 'monthly'>('weekly');
|
||||
const projectionPeriod = ref(13);
|
||||
const criticalBalance = ref(50000);
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ slot: 'projections', label: 'Projections' },
|
||||
{ slot: 'scenarios', label: 'Scenarios' },
|
||||
{ slot: 'transactions', label: 'Transactions' }
|
||||
];
|
||||
|
||||
// Table columns
|
||||
const summaryColumns = [
|
||||
{ key: 'period', label: 'Period' },
|
||||
{ key: 'inflow', label: 'Inflow' },
|
||||
{ key: 'outflow', label: 'Outflow' },
|
||||
{ key: 'netFlow', label: 'Net Flow' },
|
||||
{ key: 'balance', label: 'Ending Balance' }
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
const projectionData = computed(() => {
|
||||
if (projectionType.value === 'weekly') {
|
||||
return cashFlow.generateWeeklyProjections(projectionPeriod.value);
|
||||
} else {
|
||||
return cashFlow.generateProjections(Math.ceil(projectionPeriod.value / 4.33));
|
||||
}
|
||||
});
|
||||
|
||||
const projectionSummary = computed(() => {
|
||||
return projectionData.value.map((projection, index) => ({
|
||||
period: projectionType.value === 'weekly'
|
||||
? `Week ${index + 1}`
|
||||
: projection.date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||
inflow: formatCurrency(projection.inflow),
|
||||
outflow: formatCurrency(projection.outflow),
|
||||
netFlow: formatCurrency(projection.netFlow),
|
||||
balance: formatCurrency(projection.projectedBalance)
|
||||
}));
|
||||
});
|
||||
|
||||
const runwayScenarios = computed(() => {
|
||||
return cashFlow.calculateRunwayScenarios();
|
||||
});
|
||||
|
||||
const riskRecommendations = computed(() => {
|
||||
const risk = cashFlow.riskLevel.value;
|
||||
const recommendations = [];
|
||||
|
||||
if (risk === 'critical') {
|
||||
recommendations.push('Immediate cash injection required');
|
||||
recommendations.push('Delay all non-essential expenses');
|
||||
recommendations.push('Accelerate revenue collection');
|
||||
recommendations.push('Consider emergency funding options');
|
||||
} else if (risk === 'high') {
|
||||
recommendations.push('Monitor cash flow weekly');
|
||||
recommendations.push('Reduce discretionary spending');
|
||||
recommendations.push('Focus on converting opportunities');
|
||||
recommendations.push('Prepare contingency plans');
|
||||
} else if (risk === 'medium') {
|
||||
recommendations.push('Review monthly expenses for optimization');
|
||||
recommendations.push('Build stronger revenue pipeline');
|
||||
recommendations.push('Consider increasing cash reserves');
|
||||
} else {
|
||||
recommendations.push('Maintain current financial discipline');
|
||||
recommendations.push('Consider strategic investments');
|
||||
recommendations.push('Build reserves for future opportunities');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const updateProjectionPeriod = (weeks: number) => {
|
||||
projectionPeriod.value = weeks;
|
||||
};
|
||||
|
||||
const handleAddTransaction = () => {
|
||||
console.log("Add transaction clicked");
|
||||
// TODO: Implement transaction modal
|
||||
};
|
||||
|
||||
const handleEditTransaction = (transaction: Transaction) => {
|
||||
console.log("Edit transaction:", transaction);
|
||||
// TODO: Implement transaction modal
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
const data = {
|
||||
projections: projectionData.value,
|
||||
scenarios: runwayScenarios.value,
|
||||
transactions: cashFlow.transactions.value,
|
||||
currentBalance: cashFlow.currentBalance.value,
|
||||
exportDate: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `cashflow-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatRunwayDays = (days: number) => {
|
||||
if (days === Infinity) return "∞";
|
||||
if (days > 365) return `${Math.round(days / 365)} years`;
|
||||
if (days > 30) return `${Math.round(days / 30)} months`;
|
||||
return `${Math.round(days)} days`;
|
||||
};
|
||||
|
||||
const getScenarioColor = (days: number) => {
|
||||
if (days < 30) return "text-red-600";
|
||||
if (days < 90) return "text-orange-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
const getScenarioDescription = (scenario: string) => {
|
||||
switch (scenario) {
|
||||
case 'conservative': return 'Only confirmed transactions';
|
||||
case 'realistic': return 'Probability-weighted';
|
||||
case 'optimistic': return 'All transactions at full value';
|
||||
case 'cashOnly': return 'Current balance only';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskBadgeColor = (riskLevel: string) => {
|
||||
switch (riskLevel) {
|
||||
case "critical": return "red";
|
||||
case "high": return "orange";
|
||||
case "medium": return "yellow";
|
||||
default: return "green";
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize with some default balance if none exists
|
||||
onMounted(() => {
|
||||
if (cashFlow.currentBalance.value === 0) {
|
||||
cashFlow.setCurrentBalance(50000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
813
app/pages/index.vue
Normal file
813
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Current Balance Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">
|
||||
Available Balance
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ formatCurrency(cashFlow?.startingBalance?.value || 0) }}
|
||||
</p>
|
||||
<button
|
||||
@click="openBalanceModal"
|
||||
class="mt-2 text-xs text-blue-600 hover:text-blue-800 underline border-b border-blue-600">
|
||||
Update Balances
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runway Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">Runway</p>
|
||||
<p class="text-2xl font-bold" :class="runwayColor">
|
||||
{{ runwayText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Expenses Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">
|
||||
Core Expenses (Needs)
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
{{ formatCurrency(monthlyCoreExpenses) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Income Card -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-black">Monthly Income</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ formatCurrency(monthlyIncome) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario Control Panel -->
|
||||
<div class="bg-white border-2 border-black shadow-lg p-6 mb-8">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="mb-4 sm:mb-0">
|
||||
<h3 class="text-lg font-semibold text-black">
|
||||
Financial Scenario
|
||||
</h3>
|
||||
<p class="text-sm text-black">
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="
|
||||
scenario === 'interest' ? 'text-red-600' : 'text-green-600'
|
||||
">
|
||||
{{
|
||||
scenario === "interest"
|
||||
? "🔴 Current (Interest Payments)"
|
||||
: "🟢 Bankruptcy (Surplus Payments)"
|
||||
}}
|
||||
</span>
|
||||
- {{ scenarioDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<!-- Scenario Toggle -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm font-medium text-black">Scenario:</label>
|
||||
<select
|
||||
v-model="scenario"
|
||||
@change="updateScenario"
|
||||
class="px-3 py-2 border-2 border-black focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="interest">Interest Payments</option>
|
||||
<option value="bankruptcy">Dynamic Surplus</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Surplus Calculation Display (only show for bankruptcy scenario) -->
|
||||
<div
|
||||
v-if="scenario === 'bankruptcy'"
|
||||
class="bg-white border-2 border-black px-3 py-2 text-sm">
|
||||
<div class="font-medium text-black">
|
||||
Surplus: {{ formatCurrency(calculatedSurplusPayment) }}/month
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
Income: {{ formatCurrency(householdNetIncome) }} | Threshold:
|
||||
{{ formatCurrency(surplusThreshold) }} | Available:
|
||||
{{
|
||||
formatCurrency(
|
||||
Math.max(0, householdNetIncome - surplusThreshold)
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Transaction Modal -->
|
||||
<TransactionModal
|
||||
:is-open="isModalOpen"
|
||||
:transaction="selectedTransaction"
|
||||
@close="closeModal"
|
||||
@save="saveTransaction"
|
||||
@delete="handleDeleteTransactionFromModal" />
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div
|
||||
v-if="showDeleteConfirmation"
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div
|
||||
class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Confirm Delete
|
||||
</h3>
|
||||
<button @click="cancelDelete" class="text-gray-400 hover:text-gray-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>
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
Are you sure you want to delete this transaction? This action cannot be undone.
|
||||
</p>
|
||||
<div v-if="transactionToDelete" class="mt-3 p-3 bg-gray-50 rounded">
|
||||
<p class="text-sm font-medium">{{ transactionToDelete.description }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ formatCurrency(transactionToDelete.amount) }} - {{ transactionToDelete.category }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="cancelDelete"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500">
|
||||
Delete Transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance Modal -->
|
||||
<BalanceModal
|
||||
:is-open="isBalanceModalOpen"
|
||||
:current-balance="cashFlow?.currentBalance?.value || 0"
|
||||
@close="closeBalanceModal"
|
||||
@update="updateBalance" />
|
||||
|
||||
<!-- Projected Transactions (Next 13 Weeks) -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div
|
||||
class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">
|
||||
All Transactions - Next 13 Weeks
|
||||
</h3>
|
||||
<button
|
||||
@click="handleAddTransaction"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 border-2 border-blue-800 flex items-center">
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Transaction
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto" v-if="projectedTransactions.length > 0">
|
||||
<table class="w-full">
|
||||
<thead class="bg-black text-white">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
|
||||
Running Balance
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-black">
|
||||
<tr
|
||||
v-for="transaction in projectedTransactions"
|
||||
:key="`${transaction.id}-${transaction.date}`"
|
||||
class="hover:bg-blue-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-black">
|
||||
{{ formatDate(transaction.date) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-black">
|
||||
{{ transaction.description }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-black">{{
|
||||
transaction.category
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div
|
||||
:class="
|
||||
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
|
||||
">
|
||||
<p class="text-sm font-medium">
|
||||
{{ formatCurrency(transaction.amount) }}
|
||||
</p>
|
||||
<!-- Show business split info if applicable -->
|
||||
<div v-if="transaction.businessPercentage > 0" class="text-xs text-gray-500 space-y-0.5">
|
||||
<div>Personal: {{ formatCurrency(transaction.amount) }}</div>
|
||||
<div class="text-blue-600">Business: {{ formatCurrency(transaction.businessAmount || 0) }} ({{ transaction.businessPercentage }}%)</div>
|
||||
<div class="font-medium">Total: {{ formatCurrency(transaction.totalAmount || transaction.amount) }}</div>
|
||||
</div>
|
||||
<!-- Show original currency info if different and no business split -->
|
||||
<p
|
||||
v-else-if="
|
||||
transaction.originalCurrency &&
|
||||
transaction.originalCurrency !== 'CAD'
|
||||
"
|
||||
class="text-xs text-gray-500">
|
||||
({{
|
||||
formatCurrency(
|
||||
transaction.originalAmount,
|
||||
transaction.originalCurrency
|
||||
)
|
||||
}})
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<p
|
||||
class="text-sm font-bold"
|
||||
:class="
|
||||
transaction.runningBalance < 0
|
||||
? 'text-red-600'
|
||||
: 'text-black'
|
||||
">
|
||||
{{ formatCurrency(transaction.runningBalance) }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-left">
|
||||
<p class="text-sm text-black max-w-xs truncate" :title="transaction.notes">
|
||||
{{ transaction.notes || '-' }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex justify-center space-x-2">
|
||||
<button
|
||||
@click="handleEditTransaction(transaction)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium px-2 py-1 border border-blue-600 hover:bg-blue-50">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
@click="handleDeleteTransaction(transaction.id)"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium px-2 py-1 border border-red-600 hover:bg-red-50">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Empty state when no transactions -->
|
||||
<div v-else class="text-center py-12">
|
||||
<svg
|
||||
class="h-12 w-12 mx-auto mb-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
No transactions yet
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Add your first transaction to start tracking your cash flow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Faber Finances",
|
||||
description: "$$$",
|
||||
});
|
||||
|
||||
// Initialize cash flow composable
|
||||
const cashFlow = useCashFlow();
|
||||
|
||||
// Reactive state
|
||||
const isModalOpen = ref(false);
|
||||
const selectedTransaction = ref(null);
|
||||
const isBalanceModalOpen = ref(false);
|
||||
const newBalance = ref(0);
|
||||
const showDeleteConfirmation = ref(false);
|
||||
const transactionToDelete = ref(null);
|
||||
|
||||
// Scenario state
|
||||
const scenario = ref("interest"); // 'interest' or 'bankruptcy'
|
||||
const interestTransactionId = ref("transaction_1755673351011"); // Store Interest transaction ID
|
||||
|
||||
// Surplus calculation constants
|
||||
const surplusThreshold = 3318; // CAD threshold for 2-person household surplus calculation
|
||||
|
||||
// Calculate household net income from transactions (Personal account only)
|
||||
const householdNetIncome = computed(() => {
|
||||
if (!cashFlow?.transactions?.value) return 0;
|
||||
|
||||
return cashFlow.transactions.value
|
||||
.filter((t) => t.amount > 0) // Only income transactions (positive amounts)
|
||||
.reduce((total, transaction) => {
|
||||
if (transaction.type === "recurring") {
|
||||
// Calculate monthly income from recurring transactions
|
||||
const frequencyMultiplier = {
|
||||
weekly: 4.33, // ~4.33 weeks per month
|
||||
biweekly: 2.165, // ~2.165 biweeks per month
|
||||
monthly: 1,
|
||||
quarterly: 1 / 3, // 1 quarter = 1/3 month
|
||||
custom: 1 / (transaction.customMonths || 1),
|
||||
};
|
||||
return (
|
||||
total +
|
||||
transaction.amount *
|
||||
(frequencyMultiplier[transaction.frequency || "monthly"] || 1)
|
||||
);
|
||||
} else {
|
||||
// For one-time transactions, don't include in monthly calculation
|
||||
return total;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Calculate dynamic surplus payment
|
||||
const calculatedSurplusPayment = computed(() => {
|
||||
if (householdNetIncome.value > surplusThreshold) {
|
||||
const surplus = householdNetIncome.value - surplusThreshold;
|
||||
return Math.round(surplus * 0.5); // 50% of surplus above threshold
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Scenario descriptions
|
||||
const scenarioDescription = computed(() => {
|
||||
if (scenario.value === "interest") {
|
||||
return "Paying $2,700/month in interest expenses";
|
||||
} else {
|
||||
return `Dynamic surplus based on income: ${formatCurrency(
|
||||
calculatedSurplusPayment.value
|
||||
)}/month`;
|
||||
}
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const runwayText = computed(() => {
|
||||
const days = cashFlow?.runwayDays?.value || 0;
|
||||
if (days === Infinity) return "∞";
|
||||
if (days > 365) return `${Math.round(days / 365)} years`;
|
||||
if (days > 30) return `${Math.round(days / 30)} months`;
|
||||
return `${Math.round(days)} days`;
|
||||
});
|
||||
|
||||
const runwayColor = computed(() => {
|
||||
const days = cashFlow?.runwayDays?.value || 0;
|
||||
if (days < 30) return "text-red-600";
|
||||
if (days < 90) return "text-orange-600";
|
||||
return "text-green-600";
|
||||
});
|
||||
|
||||
// Core Expenses (Need) - Monthly recurring expenses categorized as "Expense: Need"
|
||||
const monthlyCoreExpenses = computed(() => {
|
||||
if (!cashFlow?.filteredTransactions?.value) return 0;
|
||||
|
||||
const transactions = cashFlow.filteredTransactions.value;
|
||||
let totalCoreExpenses = 0;
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// Only include "Expense: Need" category expenses (negative amounts)
|
||||
if (transaction.amount >= 0 || transaction.category !== "Expense: Need")
|
||||
return;
|
||||
|
||||
if (transaction.type === "recurring") {
|
||||
// Convert recurring transactions to monthly amounts
|
||||
const frequencyMultiplier = {
|
||||
weekly: 4.33, // ~4.33 weeks per month
|
||||
biweekly: 2.165, // ~2.165 biweeks per month
|
||||
monthly: 1,
|
||||
quarterly: 1 / 3, // 1 quarter = 1/3 month
|
||||
custom: 1 / (transaction.customMonths || 1),
|
||||
};
|
||||
|
||||
const monthlyAmount =
|
||||
Math.abs(transaction.amount) *
|
||||
(frequencyMultiplier[transaction.frequency || "monthly"] || 1);
|
||||
totalCoreExpenses += monthlyAmount * transaction.probability;
|
||||
}
|
||||
});
|
||||
|
||||
return totalCoreExpenses;
|
||||
});
|
||||
|
||||
// Monthly Income - Monthly recurring income
|
||||
const monthlyIncome = computed(() => {
|
||||
if (!cashFlow?.filteredTransactions?.value) return 0;
|
||||
|
||||
const transactions = cashFlow.filteredTransactions.value;
|
||||
let totalIncome = 0;
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// Only include income (positive amounts) - accept any income category
|
||||
if (transaction.amount <= 0) return;
|
||||
|
||||
if (transaction.type === "recurring") {
|
||||
// Convert recurring transactions to monthly amounts
|
||||
const frequencyMultiplier = {
|
||||
weekly: 4.33, // ~4.33 weeks per month
|
||||
biweekly: 2.165, // ~2.165 biweeks per month
|
||||
monthly: 1,
|
||||
quarterly: 1 / 3, // 1 quarter = 1/3 month
|
||||
custom: 1 / (transaction.customMonths || 1),
|
||||
};
|
||||
|
||||
const monthlyAmount =
|
||||
transaction.amount *
|
||||
(frequencyMultiplier[transaction.frequency || "monthly"] || 1);
|
||||
totalIncome += monthlyAmount * transaction.probability;
|
||||
}
|
||||
});
|
||||
|
||||
return totalIncome;
|
||||
});
|
||||
|
||||
const projectedTransactions = computed(() => {
|
||||
if (!cashFlow?.filteredTransactions?.value) return [];
|
||||
|
||||
const transactions = cashFlow.filteredTransactions.value;
|
||||
const projectedTransactions = [];
|
||||
const today = new Date();
|
||||
const endDate = new Date(today.getTime() + 13 * 7 * 24 * 60 * 60 * 1000); // 13 weeks from now
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
if (transaction.type === "recurring") {
|
||||
// Generate recurring transactions for the next 13 weeks
|
||||
const startDate = new Date(
|
||||
Math.max(today.getTime(), new Date(transaction.date).getTime())
|
||||
);
|
||||
const transactionEndDate = transaction.endDate
|
||||
? new Date(transaction.endDate)
|
||||
: endDate;
|
||||
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate && currentDate <= transactionEndDate) {
|
||||
projectedTransactions.push({
|
||||
...transaction,
|
||||
date: new Date(currentDate),
|
||||
isProjected: true,
|
||||
});
|
||||
|
||||
// Calculate next occurrence based on frequency
|
||||
switch (transaction.frequency) {
|
||||
case "weekly":
|
||||
currentDate = new Date(
|
||||
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
break;
|
||||
case "biweekly":
|
||||
currentDate = new Date(
|
||||
currentDate.getTime() + 14 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
break;
|
||||
case "monthly":
|
||||
currentDate = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() + 1,
|
||||
currentDate.getDate()
|
||||
);
|
||||
break;
|
||||
case "quarterly":
|
||||
currentDate = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() + 3,
|
||||
currentDate.getDate()
|
||||
);
|
||||
break;
|
||||
case "custom":
|
||||
const months = transaction.customMonths || 1;
|
||||
currentDate = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() + months,
|
||||
currentDate.getDate()
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Default to monthly
|
||||
currentDate = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() + 1,
|
||||
currentDate.getDate()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (transaction.type === "one-time") {
|
||||
// Include one-time transactions that fall within the next 13 weeks
|
||||
const transactionDate = new Date(transaction.date);
|
||||
if (transactionDate >= today && transactionDate <= endDate) {
|
||||
projectedTransactions.push({
|
||||
...transaction,
|
||||
date: transactionDate,
|
||||
isProjected: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by date (earliest first)
|
||||
const sorted = projectedTransactions.sort(
|
||||
(a, b) => new Date(a.date) - new Date(b.date)
|
||||
);
|
||||
|
||||
// Calculate running balance for each transaction
|
||||
// Start with account-specific starting balance (without projections)
|
||||
let runningBalance = cashFlow?.startingBalance?.value || 0;
|
||||
return sorted.map((transaction) => {
|
||||
runningBalance += transaction.amount;
|
||||
return {
|
||||
...transaction,
|
||||
runningBalance,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleAddTransaction = () => {
|
||||
selectedTransaction.value = null;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const handleEditTransaction = (transaction) => {
|
||||
selectedTransaction.value = transaction;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedTransaction.value = null;
|
||||
};
|
||||
|
||||
const saveTransaction = (transactionData) => {
|
||||
if (!cashFlow) return;
|
||||
|
||||
const currentTransactions = cashFlow.transactions.value;
|
||||
|
||||
if (selectedTransaction.value) {
|
||||
// Edit existing transaction
|
||||
const index = currentTransactions.findIndex(
|
||||
(t) => t.id === selectedTransaction.value.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
currentTransactions[index] = transactionData;
|
||||
}
|
||||
} else {
|
||||
// Add new transaction
|
||||
currentTransactions.push(transactionData);
|
||||
}
|
||||
|
||||
cashFlow.updateTransactions(currentTransactions);
|
||||
};
|
||||
|
||||
const openBalanceModal = () => {
|
||||
newBalance.value = cashFlow?.currentBalance?.value || 0;
|
||||
isBalanceModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeBalanceModal = () => {
|
||||
isBalanceModalOpen.value = false;
|
||||
};
|
||||
|
||||
const updateBalance = (totalBalance) => {
|
||||
if (cashFlow) {
|
||||
cashFlow.setCurrentBalance(totalBalance);
|
||||
}
|
||||
closeBalanceModal();
|
||||
};
|
||||
|
||||
// Delete transaction methods
|
||||
const handleDeleteTransaction = (transactionId) => {
|
||||
const transaction = cashFlow.transactions.value.find(t => t.id === transactionId);
|
||||
if (transaction) {
|
||||
transactionToDelete.value = transaction;
|
||||
showDeleteConfirmation.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTransactionFromModal = async (transactionId) => {
|
||||
try {
|
||||
await cashFlow.deleteTransaction(transactionId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error);
|
||||
// You could add user notification here
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirmation.value = false;
|
||||
transactionToDelete.value = null;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (transactionToDelete.value) {
|
||||
try {
|
||||
await cashFlow.deleteTransaction(transactionToDelete.value.id);
|
||||
cancelDelete();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error);
|
||||
// You could add user notification here
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount, currency = "CAD") => {
|
||||
// For CAD amounts in the main dashboard, show no decimals for cleaner look
|
||||
const decimals = currency === "CAD" ? 0 : 2;
|
||||
|
||||
return new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(amount || 0);
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
// Scenario management methods (Personal account only)
|
||||
const updateScenario = async () => {
|
||||
if (!cashFlow?.transactions?.value) return;
|
||||
// All transactions are now personal
|
||||
|
||||
const currentTransactions = [...cashFlow.transactions.value];
|
||||
|
||||
if (scenario.value === "bankruptcy") {
|
||||
// Switch to bankruptcy scenario
|
||||
// 1. Remove Interest transaction
|
||||
const filteredTransactions = currentTransactions.filter(
|
||||
(t) => !(t.description === "Interest" && t.amount === -2700)
|
||||
);
|
||||
|
||||
// 2. Add dynamic Surplus Payment transaction (only if surplus > 0)
|
||||
if (calculatedSurplusPayment.value > 0) {
|
||||
const surplusTransaction = {
|
||||
id: "bankruptcy_surplus_payment_transaction",
|
||||
description: "Bankruptcy Surplus Payment",
|
||||
amount: -calculatedSurplusPayment.value,
|
||||
category: "Expense: Need",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
date: new Date(),
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
status: "committed",
|
||||
notes: `Dynamic surplus: ${formatCurrency(
|
||||
householdNetIncome.value
|
||||
)} income - ${formatCurrency(
|
||||
surplusThreshold
|
||||
)} threshold = ${formatCurrency(
|
||||
householdNetIncome.value - surplusThreshold
|
||||
)} * 50%`,
|
||||
};
|
||||
|
||||
filteredTransactions.push(surplusTransaction);
|
||||
}
|
||||
|
||||
await cashFlow.updateTransactions(filteredTransactions);
|
||||
} else {
|
||||
// Switch to interest scenario
|
||||
// 1. Remove Surplus Payment transaction
|
||||
const filteredTransactions = currentTransactions.filter(
|
||||
(t) => t.description !== "Bankruptcy Surplus Payment"
|
||||
);
|
||||
|
||||
// 2. Add back Interest transaction
|
||||
const interestTransaction = {
|
||||
id: interestTransactionId.value,
|
||||
description: "Interest",
|
||||
amount: -2700,
|
||||
category: "Expense: Need",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
date: new Date("2025-08-29"),
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
status: "committed",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
filteredTransactions.push(interestTransaction);
|
||||
await cashFlow.updateTransactions(filteredTransactions);
|
||||
}
|
||||
};
|
||||
|
||||
// Update surplus payment when income changes (watch for transaction changes, Personal only)
|
||||
watch(
|
||||
() => calculatedSurplusPayment.value,
|
||||
async (newSurplus) => {
|
||||
if (
|
||||
scenario.value === "bankruptcy" &&
|
||||
cashFlow?.transactions?.value
|
||||
) {
|
||||
const currentTransactions = [...cashFlow.transactions.value];
|
||||
const surplusIndex = currentTransactions.findIndex(
|
||||
(t) => t.description === "Bankruptcy Surplus Payment"
|
||||
);
|
||||
|
||||
if (newSurplus > 0) {
|
||||
if (surplusIndex !== -1) {
|
||||
// Update existing surplus payment amount
|
||||
currentTransactions[surplusIndex].amount = -newSurplus;
|
||||
currentTransactions[
|
||||
surplusIndex
|
||||
].notes = `Dynamic surplus: ${formatCurrency(
|
||||
householdNetIncome.value
|
||||
)} income - ${formatCurrency(
|
||||
surplusThreshold
|
||||
)} threshold = ${formatCurrency(
|
||||
householdNetIncome.value - surplusThreshold
|
||||
)} * 50%`;
|
||||
await cashFlow.updateTransactions(currentTransactions);
|
||||
} else {
|
||||
// Add new surplus payment if it doesn't exist
|
||||
await updateScenario();
|
||||
}
|
||||
} else {
|
||||
// Remove surplus payment if surplus becomes 0
|
||||
if (surplusIndex !== -1) {
|
||||
currentTransactions.splice(surplusIndex, 1);
|
||||
await cashFlow.updateTransactions(currentTransactions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Initialize - balance will be loaded from localStorage by the composable
|
||||
</script>
|
||||
285
app/pages/migrate.vue
Normal file
285
app/pages/migrate.vue
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Data Migration</h1>
|
||||
<p class="text-gray-600 mt-2">Migrate your data from localStorage to MongoDB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<!-- Status -->
|
||||
<div v-if="status" class="mb-6 p-4 rounded" :class="statusClass">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<!-- Migration Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">1. Auto-Migration from localStorage</h3>
|
||||
<button
|
||||
@click="migrateFromLocalStorage"
|
||||
:disabled="loading"
|
||||
class="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded">
|
||||
{{ loading ? 'Migrating...' : 'Migrate from localStorage' }}
|
||||
</button>
|
||||
<p class="text-sm text-gray-600 mt-2">
|
||||
This will automatically detect and migrate any existing localStorage data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Manual Import Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">2. Manual Data Import</h3>
|
||||
|
||||
<!-- Current Balance -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Balance (CAD $)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="manualData.balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00" />
|
||||
</div>
|
||||
|
||||
<!-- Transactions JSON -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transactions JSON
|
||||
</label>
|
||||
<textarea
|
||||
v-model="manualData.transactionsJson"
|
||||
rows="10"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Paste your transactions JSON array here"></textarea>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Paste the JSON array of your transactions (the data you provided earlier)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="migrateManualData"
|
||||
:disabled="loading"
|
||||
class="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded">
|
||||
{{ loading ? 'Importing...' : 'Import Manual Data' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Data Display -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">3. Current Data Status</h3>
|
||||
<button
|
||||
@click="loadCurrentData"
|
||||
:disabled="loading"
|
||||
class="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white font-bold py-2 px-4 rounded mb-4">
|
||||
{{ loading ? 'Loading...' : 'Refresh Data Status' }}
|
||||
</button>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<p><strong>Current Balance:</strong> {{ formatCurrency(currentData.balance) }}</p>
|
||||
<p><strong>Transactions:</strong> {{ currentData.transactions.length }} items</p>
|
||||
<div v-if="currentData.transactions.length > 0" class="mt-2">
|
||||
<p class="text-sm font-medium">Sample transactions:</p>
|
||||
<ul class="text-sm text-gray-600 mt-1">
|
||||
<li v-for="transaction in currentData.transactions.slice(0, 3)" :key="transaction.id">
|
||||
{{ transaction.description }}: {{ formatCurrency(transaction.amount) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="pt-4 border-t">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">
|
||||
Back to Dashboard
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Page metadata
|
||||
useSeoMeta({
|
||||
title: 'Data Migration - Faber Finances',
|
||||
description: 'Migrate your financial data'
|
||||
});
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const status = ref('');
|
||||
const statusClass = ref('');
|
||||
|
||||
const manualData = ref({
|
||||
balance: 0,
|
||||
transactionsJson: ''
|
||||
});
|
||||
|
||||
const currentData = ref({
|
||||
balance: 0,
|
||||
transactions: []
|
||||
});
|
||||
|
||||
// Load current data status
|
||||
const loadCurrentData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Load balance
|
||||
const balanceResponse = await fetch('/api/balances');
|
||||
if (balanceResponse.ok) {
|
||||
const balanceData = await balanceResponse.json();
|
||||
currentData.value.balance = balanceData.currentBalance || 0;
|
||||
}
|
||||
|
||||
// Load transactions
|
||||
const transactionsResponse = await fetch('/api/transactions');
|
||||
if (transactionsResponse.ok) {
|
||||
const transactionsData = await transactionsResponse.json();
|
||||
currentData.value.transactions = transactionsData;
|
||||
}
|
||||
|
||||
setStatus('Data loaded successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to load current data:', error);
|
||||
setStatus('Failed to load current data: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate from localStorage
|
||||
const migrateFromLocalStorage = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Window not available');
|
||||
}
|
||||
|
||||
const savedBalance = localStorage.getItem('cashflow_balance');
|
||||
const savedTransactions = localStorage.getItem('cashflow_transactions');
|
||||
|
||||
if (!savedBalance && !savedTransactions) {
|
||||
setStatus('No localStorage data found to migrate', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationData = {
|
||||
balance: savedBalance ? parseFloat(savedBalance) : 0,
|
||||
transactions: savedTransactions ? JSON.parse(savedTransactions) : []
|
||||
};
|
||||
|
||||
const response = await fetch('/api/migrate/localStorage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(migrationData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Migration failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setStatus(
|
||||
`Migration successful! Migrated ${result.transactionsCount} transactions and balance of ${formatCurrency(result.balance)}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// Clear localStorage after successful migration
|
||||
localStorage.removeItem('cashflow_balance');
|
||||
localStorage.removeItem('cashflow_transactions');
|
||||
localStorage.removeItem('account_balances');
|
||||
|
||||
// Refresh current data
|
||||
await loadCurrentData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
setStatus('Migration failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Import manual data
|
||||
const migrateManualData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
let transactions = [];
|
||||
|
||||
if (manualData.value.transactionsJson.trim()) {
|
||||
try {
|
||||
transactions = JSON.parse(manualData.value.transactionsJson);
|
||||
if (!Array.isArray(transactions)) {
|
||||
throw new Error('Transactions must be an array');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const migrationData = {
|
||||
balance: manualData.value.balance || 0,
|
||||
transactions: transactions
|
||||
};
|
||||
|
||||
const response = await fetch('/api/migrate/localStorage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(migrationData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Import failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setStatus(
|
||||
`Import successful! Imported ${result.transactionsCount} transactions and balance of ${formatCurrency(result.balance)}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// Clear form
|
||||
manualData.value = { balance: 0, transactionsJson: '' };
|
||||
|
||||
// Refresh current data
|
||||
await loadCurrentData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
setStatus('Import failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
const setStatus = (message, type) => {
|
||||
status.value = message;
|
||||
statusClass.value = type === 'success' ? 'bg-green-100 text-green-800' :
|
||||
type === 'warning' ? 'bg-yellow-100 text-yellow-800' :
|
||||
type === 'error' ? 'bg-red-100 text-red-800' :
|
||||
'bg-blue-100 text-blue-800';
|
||||
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => {
|
||||
status.value = '';
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: 'CAD'
|
||||
}).format(amount || 0);
|
||||
};
|
||||
|
||||
// Load current data on mount
|
||||
onMounted(() => {
|
||||
loadCurrentData();
|
||||
});
|
||||
</script>
|
||||
24
app/pages/minimal.vue
Normal file
24
app/pages/minimal.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<div class="p-8">
|
||||
<h1 class="text-3xl font-bold text-blue-600">Minimal Test Page</h1>
|
||||
<p class="mt-4">If you can see this, basic routing and Vue are working!</p>
|
||||
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded">
|
||||
<p>✅ Vue template rendering: Working</p>
|
||||
<p>✅ Tailwind CSS: Working</p>
|
||||
<p>✅ Nuxt routing: Working</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="counter++"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
Counter: {{ counter }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const counter = ref(0);
|
||||
</script>
|
||||
96
app/pages/simple.vue
Normal file
96
app/pages/simple.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div class="p-8">
|
||||
<h1 class="text-2xl font-bold mb-4">Simple Test Page</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p>This is a simple test page to verify basic functionality.</p>
|
||||
|
||||
<div class="p-4 bg-blue-50 border rounded">
|
||||
<p>
|
||||
Testing composable:
|
||||
{{ composableWorking ? "Working" : "Not working" }}
|
||||
</p>
|
||||
<p>Current balance: {{ currentBalance }}</p>
|
||||
<p>Transactions count: {{ transactionCount }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-x-2">
|
||||
<button
|
||||
@click="setBalance"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
Set Balance to $50,000
|
||||
</button>
|
||||
<button
|
||||
@click="addSampleTransaction"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
|
||||
Add Sample Transaction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-4 bg-red-50 border border-red-200 rounded">
|
||||
<p class="text-red-700">Error: {{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const errorMessage = ref("");
|
||||
const composableWorking = ref(false);
|
||||
const currentBalance = ref(0);
|
||||
const transactionCount = ref(0);
|
||||
|
||||
let cashFlow = null;
|
||||
|
||||
try {
|
||||
cashFlow = useCashFlow();
|
||||
composableWorking.value = true;
|
||||
|
||||
// Watch for changes
|
||||
watchEffect(() => {
|
||||
if (cashFlow) {
|
||||
currentBalance.value = cashFlow.currentBalance.value;
|
||||
transactionCount.value = cashFlow.transactions.value.length;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage.value = `Failed to initialize cashFlow: ${error.message}`;
|
||||
console.error("CashFlow error:", error);
|
||||
}
|
||||
|
||||
const setBalance = () => {
|
||||
try {
|
||||
if (cashFlow) {
|
||||
cashFlow.setCurrentBalance(50000);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = `Failed to set balance: ${error.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
const addSampleTransaction = () => {
|
||||
try {
|
||||
if (cashFlow) {
|
||||
const sampleTransaction = {
|
||||
id: `sample-${Date.now()}`,
|
||||
date: new Date(),
|
||||
description: "Sample Transaction",
|
||||
amount: 1000,
|
||||
category: "Income",
|
||||
type: "one-time",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
};
|
||||
|
||||
cashFlow.updateTransactions([
|
||||
...cashFlow.transactions.value,
|
||||
sampleTransaction,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = `Failed to add transaction: ${error.message}`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
206
app/pages/test.vue
Normal file
206
app/pages/test.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">Cash Flow Test Page</h1>
|
||||
|
||||
<!-- Test Results -->
|
||||
<UCard class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Component Test Results</h2>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="test in testResults"
|
||||
:key="test.name"
|
||||
class="flex items-center justify-between">
|
||||
<span>{{ test.name }}</span>
|
||||
<UBadge :color="test.passed ? 'green' : 'red'">
|
||||
{{ test.passed ? "PASS" : "FAIL" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Cash Flow Summary -->
|
||||
<UCard class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Cash Flow Summary</h2>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Current Balance</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ formatCurrency(cashFlow.currentBalance.value) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Transactions</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ cashFlow.transactions.value.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Monthly Burn</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ formatCurrency(cashFlow.monthlyBurnRate.value) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Runway Days</p>
|
||||
<p class="text-xl font-bold">
|
||||
{{ Math.round(cashFlow.runwayDays.value) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Mini Chart Test -->
|
||||
<UCard class="mb-6">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Chart Test (8 weeks)</h2>
|
||||
</template>
|
||||
<CashFlowChart :data="testProjections" :critical-balance="30000" />
|
||||
</UCard>
|
||||
|
||||
<!-- Mini Transaction Manager Test -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Transaction Manager Test</h2>
|
||||
</template>
|
||||
<RecurringTransactionManager
|
||||
:transactions="cashFlow.transactions.value"
|
||||
@add-transaction="handleAddTransaction"
|
||||
@edit-transaction="handleEditTransaction" />
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Transaction } from "~/types/cashflow";
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Test Page",
|
||||
description: "Testing cash flow functionality",
|
||||
});
|
||||
|
||||
// Initialize cash flow
|
||||
const cashFlow = useCashFlow();
|
||||
|
||||
// Test data
|
||||
const testTransactions: Transaction[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
date: new Date("2024-01-01"),
|
||||
description: "Test Salary",
|
||||
amount: 10000,
|
||||
category: "Income",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
status: "committed",
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
date: new Date("2024-01-01"),
|
||||
description: "Test Rent",
|
||||
amount: -2000,
|
||||
category: "Expense: Need",
|
||||
type: "recurring",
|
||||
frequency: "monthly",
|
||||
probability: 1.0,
|
||||
isConfirmed: true,
|
||||
status: "committed",
|
||||
},
|
||||
];
|
||||
|
||||
// Test results
|
||||
const testResults = computed(() => {
|
||||
const results = [];
|
||||
|
||||
// Test 1: Composable loads
|
||||
try {
|
||||
results.push({
|
||||
name: "useCashFlow composable loads",
|
||||
passed: !!cashFlow,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "useCashFlow composable loads",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test 2: Transaction updates work
|
||||
try {
|
||||
results.push({
|
||||
name: "Transaction updates work",
|
||||
passed: typeof cashFlow.updateTransactions === "function",
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "Transaction updates work",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test 3: Projections generate
|
||||
try {
|
||||
const projections = cashFlow.generateWeeklyProjections(4);
|
||||
results.push({
|
||||
name: "Weekly projections generate",
|
||||
passed: Array.isArray(projections) && projections.length > 0,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "Weekly projections generate",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test 4: Runway calculation works
|
||||
try {
|
||||
const runway = cashFlow.runwayDays.value;
|
||||
results.push({
|
||||
name: "Runway calculation works",
|
||||
passed: typeof runway === "number",
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: "Runway calculation works",
|
||||
passed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
const testProjections = computed(() => {
|
||||
return cashFlow.generateWeeklyProjections(8);
|
||||
});
|
||||
|
||||
const handleAddTransaction = () => {
|
||||
console.log("Add transaction test");
|
||||
};
|
||||
|
||||
const handleEditTransaction = (transaction: Transaction) => {
|
||||
console.log("Edit transaction test:", transaction.description);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Initialize test data
|
||||
onMounted(() => {
|
||||
cashFlow.setCurrentBalance(75000);
|
||||
cashFlow.updateTransactions(testTransactions);
|
||||
});
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue