Init commit!

This commit is contained in:
Jennie Robinson Faber 2025-08-22 18:36:16 +01:00
commit 086d682592
34 changed files with 19249 additions and 0 deletions

View 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>