440 lines
No EOL
13 KiB
Vue
440 lines
No EOL
13 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<!-- Chart Header -->
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900">
|
|
Cash Flow Projection
|
|
</h3>
|
|
<p class="text-sm text-gray-600">
|
|
{{ selectedWeeks }}-week runway analysis
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center space-x-6">
|
|
<!-- Week Controls -->
|
|
<div class="flex space-x-2">
|
|
<UButton
|
|
@click="selectedWeeks = 8"
|
|
:variant="selectedWeeks === 8 ? 'solid' : 'outline'"
|
|
size="sm">
|
|
8 Weeks
|
|
</UButton>
|
|
<UButton
|
|
@click="selectedWeeks = 13"
|
|
:variant="selectedWeeks === 13 ? 'solid' : 'outline'"
|
|
size="sm">
|
|
13 Weeks
|
|
</UButton>
|
|
<UButton
|
|
@click="selectedWeeks = 26"
|
|
:variant="selectedWeeks === 26 ? 'solid' : 'outline'"
|
|
size="sm">
|
|
26 Weeks
|
|
</UButton>
|
|
</div>
|
|
|
|
<!-- Balance Info -->
|
|
<div class="flex space-x-4">
|
|
<div class="text-center">
|
|
<p class="text-sm text-gray-600">Starting Balance</p>
|
|
<p class="font-semibold text-blue-600">
|
|
{{ formatCurrency(startingBalance) }}
|
|
</p>
|
|
</div>
|
|
<div class="text-center">
|
|
<p class="text-sm text-gray-600">Projected Balance</p>
|
|
<p class="font-semibold" :class="projectedBalanceColor">
|
|
{{ formatCurrency(endingBalance) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart Container -->
|
|
<div class="relative h-64 bg-gray-50 rounded-lg p-4">
|
|
<!-- Y-axis labels -->
|
|
<div
|
|
class="absolute left-4 top-4 bottom-8 w-20 flex flex-col justify-between text-xs text-gray-500">
|
|
<span>{{ formatCurrency(maxValue) }}</span>
|
|
<span>{{ formatCurrency(maxValue * 0.75) }}</span>
|
|
<span>{{ formatCurrency(maxValue * 0.5) }}</span>
|
|
<span>{{ formatCurrency(maxValue * 0.25) }}</span>
|
|
<span>$0</span>
|
|
</div>
|
|
|
|
<!-- Chart area -->
|
|
<div class="ml-24 mr-16 h-full relative">
|
|
<!-- Zero line -->
|
|
<div
|
|
class="absolute w-full border-t-2 border-red-300 border-dashed"
|
|
:style="`bottom: ${getYPosition(0)}%`">
|
|
<span
|
|
class="absolute -right-12 -top-2 text-xs text-red-500 bg-white px-1"
|
|
>$0</span
|
|
>
|
|
</div>
|
|
|
|
<!-- Critical balance line -->
|
|
<div
|
|
v-if="criticalBalance > 0 && criticalBalance <= maxValue"
|
|
class="absolute w-full border-t border-orange-400 border-dashed"
|
|
:style="`bottom: ${getYPosition(criticalBalance)}%`">
|
|
<span
|
|
class="absolute -right-12 -top-2 text-xs text-orange-600 bg-white px-1"
|
|
>Critical</span
|
|
>
|
|
</div>
|
|
|
|
<!-- Cash flow line -->
|
|
<svg
|
|
class="w-full h-full"
|
|
viewBox="0 0 400 240"
|
|
preserveAspectRatio="xMidYMid meet">
|
|
<!-- Grid lines -->
|
|
<defs>
|
|
<pattern
|
|
id="grid"
|
|
width="30.77"
|
|
height="40"
|
|
patternUnits="userSpaceOnUse">
|
|
<path
|
|
d="M 30.77 0 L 0 0 0 40"
|
|
fill="none"
|
|
stroke="#e5e7eb"
|
|
stroke-width="0.5" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
|
|
<!-- Area fill -->
|
|
<defs>
|
|
<linearGradient
|
|
id="cashFlowGradient"
|
|
x1="0%"
|
|
y1="0%"
|
|
x2="0%"
|
|
y2="100%">
|
|
<stop
|
|
offset="0%"
|
|
style="stop-color: #3b82f6; stop-opacity: 0.3" />
|
|
<stop
|
|
offset="100%"
|
|
style="stop-color: #3b82f6; stop-opacity: 0.05" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<path :d="cashFlowPath" fill="url(#cashFlowGradient)" stroke="none" />
|
|
|
|
<!-- Cash flow line -->
|
|
<path
|
|
:d="cashFlowPath"
|
|
fill="none"
|
|
stroke="#3b82f6"
|
|
stroke-width="3"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round" />
|
|
|
|
<!-- Data points -->
|
|
<circle
|
|
v-for="(point, index) in chartPoints"
|
|
:key="index"
|
|
:cx="point.x"
|
|
:cy="point.y"
|
|
r="4"
|
|
:fill="getPointColor(point.balance)"
|
|
stroke="white"
|
|
stroke-width="2"
|
|
class="cursor-pointer hover:r-6 transition-all"
|
|
@mouseenter="showTooltip(index, $event)"
|
|
@mouseleave="hideTooltip" />
|
|
|
|
<!-- X-axis labels inside SVG -->
|
|
<text
|
|
v-for="(point, index) in xAxisLabelPoints"
|
|
:key="`label-${index}`"
|
|
:x="point.x"
|
|
:y="220"
|
|
text-anchor="middle"
|
|
fill="#6b7280"
|
|
font-size="10"
|
|
font-family="system-ui, -apple-system, sans-serif">
|
|
{{ point.label }}
|
|
</text>
|
|
</svg>
|
|
|
|
<!-- Tooltip -->
|
|
<div
|
|
v-if="tooltip.show"
|
|
class="absolute z-10 bg-gray-900 text-white text-xs rounded py-2 px-3 pointer-events-none"
|
|
:style="tooltipStyle">
|
|
<div class="font-semibold">Week {{ tooltip.week }}</div>
|
|
<div>Balance: {{ formatCurrency(tooltip.balance) }}</div>
|
|
<div>Cash Flow: {{ formatCurrency(tooltip.netFlow) }}</div>
|
|
<div>{{ formatDate(tooltip.date) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="flex items-center justify-center space-x-6 text-sm">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-4 h-0.5 bg-blue-500"></div>
|
|
<span class="text-gray-600">Cash Flow</span>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-4 h-0.5 border-t-2 border-red-300 border-dashed"></div>
|
|
<span class="text-gray-600">Break Even</span>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<div class="w-4 h-0.5 border-t border-orange-400 border-dashed"></div>
|
|
<span class="text-gray-600">Critical Level</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Stats -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
|
<div class="text-center p-3 bg-blue-50 rounded-lg">
|
|
<p class="text-sm font-medium text-blue-800">Lowest Point</p>
|
|
<p class="text-lg font-bold text-blue-600">
|
|
{{ formatCurrency(lowestBalance) }}
|
|
</p>
|
|
<p class="text-xs text-blue-600">Week {{ lowestWeek }}</p>
|
|
</div>
|
|
<div class="text-center p-3 bg-green-50 rounded-lg">
|
|
<p class="text-sm font-medium text-green-800">Total Inflow</p>
|
|
<p class="text-lg font-bold text-green-600">
|
|
{{ formatCurrency(totalInflow) }}
|
|
</p>
|
|
</div>
|
|
<div class="text-center p-3 bg-red-50 rounded-lg">
|
|
<p class="text-sm font-medium text-red-800">Total Outflow</p>
|
|
<p class="text-lg font-bold text-red-600">
|
|
{{ formatCurrency(totalOutflow) }}
|
|
</p>
|
|
</div>
|
|
<div class="text-center p-3 bg-purple-50 rounded-lg">
|
|
<p class="text-sm font-medium text-purple-800">Net Change</p>
|
|
<p class="text-lg font-bold text-purple-600">
|
|
{{ formatCurrency(netChange) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { CashFlowProjection } from "~/types/cashflow";
|
|
|
|
interface Props {
|
|
data: CashFlowProjection[];
|
|
criticalBalance?: number;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
criticalBalance: 50000,
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
"weeks-changed": [weeks: number];
|
|
}>();
|
|
|
|
// Week selection
|
|
const selectedWeeks = ref(13);
|
|
|
|
// Watch for changes and emit to parent
|
|
watch(selectedWeeks, (newWeeks) => {
|
|
emit("weeks-changed", newWeeks);
|
|
});
|
|
|
|
// Reactive state for tooltip
|
|
const tooltip = ref({
|
|
show: false,
|
|
week: 0,
|
|
balance: 0,
|
|
netFlow: 0,
|
|
date: new Date(),
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
|
|
// Computed properties
|
|
const startingBalance = computed(() =>
|
|
props.data.length > 0 ? props.data[0].balance : 0
|
|
);
|
|
|
|
const endingBalance = computed(() =>
|
|
props.data.length > 0 ? props.data[props.data.length - 1].projectedBalance : 0
|
|
);
|
|
|
|
const maxValue = computed(() => {
|
|
const balances = props.data.map((d) =>
|
|
Math.max(d.balance, d.projectedBalance)
|
|
);
|
|
const max = Math.max(...balances, props.criticalBalance);
|
|
return Math.ceil((max * 1.1) / 10000) * 10000;
|
|
});
|
|
|
|
const minValue = computed(() => {
|
|
const balances = props.data.map((d) =>
|
|
Math.min(d.balance, d.projectedBalance)
|
|
);
|
|
return Math.min(...balances, 0);
|
|
});
|
|
|
|
const chartPoints = computed(() => {
|
|
if (props.data.length === 0) return [];
|
|
|
|
return props.data.map((point, index) => {
|
|
const x =
|
|
props.data.length === 1 ? 200 : (index / (props.data.length - 1)) * 400;
|
|
const y = 200 - (getYPosition(point.projectedBalance) / 100) * 200;
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
balance: point.projectedBalance,
|
|
date: point.date,
|
|
netFlow: point.netFlow,
|
|
};
|
|
});
|
|
});
|
|
|
|
const cashFlowPath = computed(() => {
|
|
if (chartPoints.value.length === 0) return "";
|
|
|
|
let path = `M ${chartPoints.value[0].x} ${chartPoints.value[0].y}`;
|
|
|
|
for (let i = 1; i < chartPoints.value.length; i++) {
|
|
path += ` L ${chartPoints.value[i].x} ${chartPoints.value[i].y}`;
|
|
}
|
|
|
|
const lastPoint = chartPoints.value[chartPoints.value.length - 1];
|
|
const firstPoint = chartPoints.value[0];
|
|
path += ` L ${lastPoint.x} 200 L ${firstPoint.x} 200 Z`;
|
|
|
|
return path;
|
|
});
|
|
|
|
const xAxisLabelPoints = computed(() => {
|
|
if (props.data.length === 0) return [];
|
|
|
|
const maxLabels = 6;
|
|
const step = Math.max(1, Math.floor(props.data.length / maxLabels));
|
|
const labels = [];
|
|
|
|
for (let i = 0; i < props.data.length; i += step) {
|
|
const point = props.data[i];
|
|
const x =
|
|
props.data.length === 1 ? 200 : (i / (props.data.length - 1)) * 400;
|
|
|
|
labels.push({
|
|
label: point.date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
}),
|
|
x: x,
|
|
});
|
|
}
|
|
|
|
if (props.data.length > 1) {
|
|
const lastIndex = props.data.length - 1;
|
|
const lastIncluded = labels[labels.length - 1];
|
|
const lastX = (lastIndex / (props.data.length - 1)) * 400;
|
|
|
|
if (Math.abs(lastIncluded.x - lastX) > 20) {
|
|
labels.push({
|
|
label: props.data[lastIndex].date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
}),
|
|
x: 400,
|
|
});
|
|
}
|
|
}
|
|
|
|
return labels;
|
|
});
|
|
|
|
const projectedBalanceColor = computed(() => {
|
|
if (endingBalance.value < 0) return "text-red-600";
|
|
if (endingBalance.value < props.criticalBalance) return "text-orange-600";
|
|
if (endingBalance.value < startingBalance.value) return "text-yellow-600";
|
|
return "text-green-600";
|
|
});
|
|
|
|
const lowestBalance = computed(() =>
|
|
Math.min(...props.data.map((d) => d.projectedBalance))
|
|
);
|
|
|
|
const lowestWeek = computed(() => {
|
|
const lowest = lowestBalance.value;
|
|
const index = props.data.findIndex((d) => d.projectedBalance === lowest);
|
|
return index + 1;
|
|
});
|
|
|
|
const totalInflow = computed(() =>
|
|
props.data.reduce((sum, d) => sum + d.inflow, 0)
|
|
);
|
|
|
|
const totalOutflow = computed(() =>
|
|
props.data.reduce((sum, d) => sum + Math.abs(d.outflow), 0)
|
|
);
|
|
|
|
const netChange = computed(() => endingBalance.value - startingBalance.value);
|
|
|
|
const tooltipStyle = computed(() => ({
|
|
left: `${tooltip.value.x}px`,
|
|
top: `${tooltip.value.y - 60}px`,
|
|
transform: "translateX(-50%)",
|
|
}));
|
|
|
|
// Helper functions
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(amount);
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
}).format(new Date(date));
|
|
};
|
|
|
|
const getYPosition = (value: number) => {
|
|
const range = maxValue.value - minValue.value;
|
|
return ((value - minValue.value) / range) * 100;
|
|
};
|
|
|
|
const getPointColor = (balance: number) => {
|
|
if (balance < 0) return "#dc2626";
|
|
if (balance < props.criticalBalance) return "#ea580c";
|
|
if (balance < props.criticalBalance * 1.5) return "#d97706";
|
|
return "#059669";
|
|
};
|
|
|
|
const showTooltip = (index: number, event: MouseEvent) => {
|
|
const point = props.data[index];
|
|
if (point) {
|
|
tooltip.value = {
|
|
show: true,
|
|
week: index + 1,
|
|
balance: point.projectedBalance,
|
|
netFlow: point.netFlow,
|
|
date: point.date,
|
|
x: event.offsetX,
|
|
y: event.offsetY,
|
|
};
|
|
}
|
|
};
|
|
|
|
const hideTooltip = () => {
|
|
tooltip.value.show = false;
|
|
};
|
|
</script> |