Init commit!
This commit is contained in:
commit
086d682592
34 changed files with 19249 additions and 0 deletions
440
app/components/CashFlowChart.vue
Normal file
440
app/components/CashFlowChart.vue
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue