368 lines
13 KiB
Vue
368 lines
13 KiB
Vue
<template>
|
|
<section class="py-8 space-y-6">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-2xl font-semibold">Operating Plan</h2>
|
|
<USelect
|
|
v-model="selectedMonth"
|
|
:options="months"
|
|
placeholder="Select month" />
|
|
</div>
|
|
|
|
<!-- Cash Waterfall Summary -->
|
|
<UCard>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium">
|
|
Cash Waterfall - {{ selectedMonth }}
|
|
</h3>
|
|
</template>
|
|
<div
|
|
class="flex items-center justify-between py-4 border-b border-neutral-200">
|
|
<div class="flex items-center gap-8">
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold text-blue-600">
|
|
€{{ budgetMetrics.grossRevenue.toLocaleString() }}
|
|
</div>
|
|
<div class="text-xs text-neutral-600">Gross Revenue</div>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold text-red-600">
|
|
-€{{ budgetMetrics.totalFees.toLocaleString() }}
|
|
</div>
|
|
<div class="text-xs text-neutral-600">Fees</div>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold text-green-600">
|
|
€{{ budgetMetrics.netRevenue.toLocaleString() }}
|
|
</div>
|
|
<div class="text-xs text-neutral-600">Net Revenue</div>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold text-blue-600">
|
|
€{{ Math.round(budgetMetrics.savingsAmount).toLocaleString() }}
|
|
</div>
|
|
<div class="text-xs text-neutral-600">To Savings</div>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold text-purple-600">
|
|
€{{ Math.round(budgetMetrics.totalPayroll).toLocaleString() }}
|
|
</div>
|
|
<div class="text-xs text-neutral-600">Payroll</div>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold text-orange-600">
|
|
€{{ budgetMetrics.totalOverhead.toLocaleString() }}
|
|
</div>
|
|
<div class="text-xs text-neutral-600">Overhead</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="pt-4">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-lg font-medium">Available for Operations</span>
|
|
<span class="text-2xl font-bold text-green-600"
|
|
>€{{
|
|
Math.round(budgetMetrics.availableForOps).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<!-- Monthly Revenue Table -->
|
|
<UCard>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium">Revenue by Stream</h3>
|
|
</template>
|
|
<UTable :rows="revenueStreams" :columns="revenueColumns">
|
|
<template #name-data="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium">{{ row.name }}</span>
|
|
<RestrictionChip :restriction="row.restrictions" size="xs" />
|
|
</div>
|
|
</template>
|
|
|
|
<template #target-data="{ row }">
|
|
<span class="font-medium">€{{ row.target.toLocaleString() }}</span>
|
|
</template>
|
|
|
|
<template #committed-data="{ row }">
|
|
<span class="font-medium text-green-600"
|
|
>€{{ row.committed.toLocaleString() }}</span
|
|
>
|
|
</template>
|
|
|
|
<template #actual-data="{ row }">
|
|
<span
|
|
class="font-medium"
|
|
:class="
|
|
row.actual >= row.committed ? 'text-green-600' : 'text-orange-600'
|
|
">
|
|
€{{ row.actual.toLocaleString() }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #variance-data="{ row }">
|
|
<span :class="row.variance >= 0 ? 'text-green-600' : 'text-red-600'">
|
|
{{ row.variance >= 0 ? "+" : "" }}€{{
|
|
row.variance.toLocaleString()
|
|
}}
|
|
</span>
|
|
</template>
|
|
</UTable>
|
|
</UCard>
|
|
|
|
<!-- Costs Breakdown -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<UCard>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium">Costs</h3>
|
|
</template>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<h4 class="font-medium text-sm mb-2">Payroll</h4>
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600"
|
|
>Wages ({{ budgetMetrics.totalHours }}h @ €{{
|
|
budgetMetrics.hourlyWage
|
|
}})</span
|
|
>
|
|
<span class="font-medium"
|
|
>€{{
|
|
Math.round(budgetMetrics.grossWages).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600"
|
|
>On-costs ({{ budgetMetrics.oncostPct }}%)</span
|
|
>
|
|
<span class="font-medium"
|
|
>€{{
|
|
Math.round(budgetMetrics.oncosts).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="flex justify-between text-sm font-medium border-t pt-2">
|
|
<span>Total Payroll</span>
|
|
<span
|
|
>€{{
|
|
Math.round(budgetMetrics.totalPayroll).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="font-medium text-sm mb-2">Overhead</h4>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-if="budgetStore.overheadCosts.length === 0"
|
|
class="text-sm text-neutral-500 italic">
|
|
No overhead costs added yet
|
|
</div>
|
|
<div
|
|
v-for="cost in budgetStore.overheadCosts"
|
|
:key="cost.id"
|
|
class="flex justify-between text-sm">
|
|
<span class="text-neutral-600">{{ cost.name }}</span>
|
|
<span class="font-medium"
|
|
>€{{ (cost.amount || 0).toLocaleString() }}</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="flex justify-between text-sm font-medium border-t pt-2">
|
|
<span>Total Overhead</span>
|
|
<span>€{{ budgetMetrics.totalOverhead.toLocaleString() }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="font-medium text-sm mb-2">Production</h4>
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600">Dev kits</span>
|
|
<span class="font-medium">€500</span>
|
|
</div>
|
|
<div
|
|
class="flex justify-between text-sm font-medium border-t pt-2">
|
|
<span>Total Production</span>
|
|
<span>€500</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium">Net Impact on Savings</h3>
|
|
</template>
|
|
<div class="space-y-4">
|
|
<div class="space-y-3">
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600">Net Revenue</span>
|
|
<span class="font-medium text-green-600"
|
|
>€{{ budgetMetrics.netRevenue.toLocaleString() }}</span
|
|
>
|
|
</div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600">Total Costs</span>
|
|
<span class="font-medium text-red-600"
|
|
>-€{{
|
|
Math.round(budgetMetrics.totalCosts).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div class="flex justify-between text-lg font-bold border-t pt-3">
|
|
<span>Net</span>
|
|
<span
|
|
:class="
|
|
budgetMetrics.monthlyNet >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
"
|
|
>{{ budgetMetrics.monthlyNet >= 0 ? "+" : "" }}€{{
|
|
Math.round(budgetMetrics.monthlyNet).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-neutral-50 rounded-lg p-4">
|
|
<h4 class="font-medium text-sm mb-3">Allocation</h4>
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600">To Savings</span>
|
|
<span class="font-medium"
|
|
>€{{
|
|
Math.round(budgetMetrics.savingsAmount).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-neutral-600">Available</span>
|
|
<span class="font-medium"
|
|
>€{{
|
|
Math.round(
|
|
budgetMetrics.availableAfterSavings
|
|
).toLocaleString()
|
|
}}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-xs text-neutral-600 space-y-1">
|
|
<p>
|
|
<RestrictionChip restriction="Restricted" size="xs" /> funds can
|
|
only be used for approved purposes.
|
|
</p>
|
|
<p>
|
|
<RestrictionChip restriction="General" size="xs" /> funds have no
|
|
restrictions.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// Use real store data
|
|
const membersStore = useMembersStore();
|
|
const policiesStore = usePoliciesStore();
|
|
const streamsStore = useStreamsStore();
|
|
const budgetStore = useBudgetStore();
|
|
const cashStore = useCashStore();
|
|
|
|
const selectedMonth = ref("2024-01");
|
|
const months = ref([
|
|
{ label: "January 2024", value: "2024-01" },
|
|
{ label: "February 2024", value: "2024-02" },
|
|
{ label: "March 2024", value: "2024-03" },
|
|
]);
|
|
|
|
// Calculate budget values from real data
|
|
const budgetMetrics = computed(() => {
|
|
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
|
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
|
const oncostPct = policiesStore.payrollOncostPct || 0;
|
|
|
|
const grossWages = totalHours * hourlyWage;
|
|
const oncosts = grossWages * (oncostPct / 100);
|
|
const totalPayroll = grossWages + oncosts;
|
|
|
|
const totalOverhead = budgetStore.overheadCosts.reduce(
|
|
(sum, cost) => sum + (cost.amount || 0),
|
|
0
|
|
);
|
|
const grossRevenue = streamsStore.totalMonthlyAmount || 0;
|
|
|
|
// Calculate fees from streams with platform fees
|
|
const totalFees = streamsStore.streams.reduce((sum, stream) => {
|
|
const revenue = stream.targetMonthlyAmount || 0;
|
|
const platformFee = (stream.platformFeePct || 0) / 100;
|
|
const revShareFee = (stream.revenueSharePct || 0) / 100;
|
|
return sum + revenue * platformFee + revenue * revShareFee;
|
|
}, 0);
|
|
|
|
const netRevenue = grossRevenue - totalFees;
|
|
const totalCosts = totalPayroll + totalOverhead;
|
|
const monthlyNet = netRevenue - totalCosts;
|
|
const savingsAmount = Math.max(0, monthlyNet * 0.3); // Save 30% of positive net if possible
|
|
const availableAfterSavings = Math.max(0, monthlyNet - savingsAmount);
|
|
const availableForOps = Math.max(
|
|
0,
|
|
netRevenue - totalPayroll - totalOverhead - savingsAmount
|
|
);
|
|
|
|
return {
|
|
grossRevenue,
|
|
totalFees,
|
|
netRevenue,
|
|
totalCosts,
|
|
monthlyNet,
|
|
savingsAmount,
|
|
availableAfterSavings,
|
|
totalPayroll,
|
|
grossWages,
|
|
oncosts,
|
|
totalOverhead,
|
|
availableForOps,
|
|
totalHours,
|
|
hourlyWage,
|
|
oncostPct,
|
|
};
|
|
});
|
|
|
|
// Convert streams to budget table format
|
|
const revenueStreams = computed(() =>
|
|
streamsStore.streams.map((stream) => ({
|
|
id: stream.id,
|
|
name: stream.name,
|
|
target: stream.targetMonthlyAmount || 0,
|
|
committed: Math.round((stream.targetMonthlyAmount || 0) * 0.8), // 80% committed assumption
|
|
actual: Math.round((stream.targetMonthlyAmount || 0) * 0.9), // 90% actual assumption
|
|
variance: Math.round((stream.targetMonthlyAmount || 0) * 0.1), // 10% positive variance
|
|
restrictions: stream.restrictions || "General",
|
|
}))
|
|
);
|
|
|
|
const revenueColumns = [
|
|
{ id: "name", key: "name", label: "Stream" },
|
|
{ id: "target", key: "target", label: "Target" },
|
|
{ id: "committed", key: "committed", label: "Committed" },
|
|
{ id: "actual", key: "actual", label: "Actual" },
|
|
{ id: "variance", key: "variance", label: "Variance" },
|
|
];
|
|
</script>
|