refactor: update routing paths in app.vue, enhance AnnualBudget component layout, and streamline dashboard and budget pages for improved user experience
This commit is contained in:
parent
09d8794d72
commit
864a81065c
23 changed files with 3211 additions and 1978 deletions
295
components/CashFlowChart.vue
Normal file
295
components/CashFlowChart.vue
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
<template>
|
||||
<div class="w-full h-full">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
class="w-full h-full"
|
||||
:width="width"
|
||||
:height="height"
|
||||
></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ChartData {
|
||||
month: number
|
||||
monthName: string
|
||||
revenue: number
|
||||
expenses: number
|
||||
netCashFlow: number
|
||||
runningBalance: number
|
||||
oneOffEvents?: Array<{ type: string, amount: number, name: string }>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ChartData[]
|
||||
viewMode: 'combined' | 'runway' | 'cashflow'
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 800,
|
||||
height: 320
|
||||
})
|
||||
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
function drawChart() {
|
||||
if (!chartCanvas.value || !props.data.length) return
|
||||
|
||||
const canvas = chartCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const padding = 60
|
||||
const chartWidth = canvas.width - padding * 2
|
||||
const chartHeight = canvas.height - padding * 2
|
||||
|
||||
const dataLength = props.data.length
|
||||
const scaleX = chartWidth / (dataLength - 1)
|
||||
|
||||
// Calculate data ranges based on view mode
|
||||
let maxValue = 0
|
||||
let minValue = 0
|
||||
|
||||
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
|
||||
const balances = props.data.map(d => d.runningBalance)
|
||||
maxValue = Math.max(maxValue, ...balances)
|
||||
minValue = Math.min(minValue, ...balances)
|
||||
}
|
||||
|
||||
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
|
||||
const revenues = props.data.map(d => d.revenue)
|
||||
const expenses = props.data.map(d => d.expenses)
|
||||
maxValue = Math.max(maxValue, ...revenues, ...expenses)
|
||||
}
|
||||
|
||||
// Add some padding to the range
|
||||
const range = maxValue - minValue
|
||||
maxValue += range * 0.1
|
||||
minValue -= range * 0.1
|
||||
|
||||
// Ensure we show zero line
|
||||
if (minValue > 0) minValue = Math.min(0, minValue)
|
||||
if (maxValue < 0) maxValue = Math.max(0, maxValue)
|
||||
|
||||
const valueRange = maxValue - minValue
|
||||
const scaleY = valueRange > 0 ? chartHeight / valueRange : 1
|
||||
|
||||
// Helper functions
|
||||
const getX = (index: number) => padding + (index * scaleX)
|
||||
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
|
||||
|
||||
// Draw background for negative values
|
||||
if (minValue < 0) {
|
||||
const zeroY = getY(0)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
|
||||
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
drawGrid(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange, scaleY)
|
||||
|
||||
// Draw data based on view mode
|
||||
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
|
||||
drawCashFlowData(ctx, getX, getY)
|
||||
}
|
||||
|
||||
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
|
||||
drawRunwayData(ctx, getX, getY)
|
||||
}
|
||||
|
||||
// Draw one-off events
|
||||
drawOneOffEvents(ctx, getX, getY)
|
||||
|
||||
// Draw axis labels
|
||||
drawAxisLabels(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange)
|
||||
}
|
||||
|
||||
function drawGrid(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number, scaleY: number) {
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// Horizontal grid lines
|
||||
const gridLines = 5
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padding + (chartHeight / gridLines) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(padding + chartWidth, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Vertical grid lines (every 3 months)
|
||||
for (let i = 0; i < props.data.length; i += 3) {
|
||||
const x = padding + (i * chartWidth) / (props.data.length - 1)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Zero line if applicable
|
||||
if (minValue < 0 && maxValue > 0) {
|
||||
const zeroY = padding + chartHeight - ((0 - minValue) * scaleY)
|
||||
ctx.strokeStyle = '#6b7280'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, zeroY)
|
||||
ctx.lineTo(padding + chartWidth, zeroY)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function drawCashFlowData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
// Draw revenue line
|
||||
ctx.strokeStyle = '#3b82f6'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.revenue)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Draw expenses line
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.expenses)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawRunwayData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
// Draw balance line
|
||||
ctx.strokeStyle = '#10b981'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.runningBalance)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Mark zero crossing points
|
||||
ctx.fillStyle = '#ef4444'
|
||||
for (let i = 1; i < props.data.length; i++) {
|
||||
const prev = props.data[i - 1].runningBalance
|
||||
const current = props.data[i].runningBalance
|
||||
|
||||
if (prev >= 0 && current < 0) {
|
||||
// Crossed from positive to negative
|
||||
const x = getX(i)
|
||||
const y = getY(0)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add warning label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of Money', x, y - 10)
|
||||
ctx.fillStyle = '#ef4444' // Reset for next point
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawOneOffEvents(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
if (dataPoint.oneOffEvents && dataPoint.oneOffEvents.length > 0) {
|
||||
const x = getX(index)
|
||||
let yOffset = 0
|
||||
|
||||
dataPoint.oneOffEvents.forEach(event => {
|
||||
const isIncome = event.type === 'income'
|
||||
const baseY = getY(isIncome ? dataPoint.revenue : dataPoint.expenses)
|
||||
const y = baseY + yOffset
|
||||
|
||||
// Draw event marker
|
||||
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add tooltip-style label
|
||||
if (event.amount > 0) {
|
||||
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
const shortName = event.name.length > 15 ? event.name.substring(0, 12) + '...' : event.name
|
||||
ctx.fillText(shortName, x, y - 8)
|
||||
}
|
||||
|
||||
yOffset += 15
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawAxisLabels(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number) {
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '12px sans-serif'
|
||||
|
||||
// X-axis labels (months)
|
||||
ctx.textAlign = 'center'
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
if (index % 2 === 0) { // Show every other month to avoid crowding
|
||||
const x = padding + (index * chartWidth) / (props.data.length - 1)
|
||||
ctx.fillText(monthNames[dataPoint.month], x, padding + chartHeight + 20)
|
||||
}
|
||||
})
|
||||
|
||||
// Y-axis labels (values)
|
||||
ctx.textAlign = 'right'
|
||||
const gridLines = 5
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const value = minValue + (valueRange / gridLines) * (gridLines - i)
|
||||
const y = padding + (chartHeight / gridLines) * i + 4
|
||||
ctx.fillText(formatShort(value), padding - 10, y)
|
||||
}
|
||||
}
|
||||
|
||||
function formatShort(value: number): string {
|
||||
if (Math.abs(value) >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return `${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
return Math.round(value).toString()
|
||||
}
|
||||
|
||||
// Watch for changes and redraw
|
||||
watch(() => [props.data, props.viewMode], () => {
|
||||
nextTick(() => drawChart())
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => drawChart())
|
||||
})
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue