291 lines
8.4 KiB
Vue
291 lines
8.4 KiB
Vue
<template>
|
|
<section class="py-8 space-y-6">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
|
|
<UButton color="primary" @click="sendToBudget">
|
|
Send to Budget & Scenarios
|
|
</UButton>
|
|
</div>
|
|
|
|
<!-- Concentration Overview -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<UCard>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium">Concentration Risk</h3>
|
|
</template>
|
|
<div class="space-y-4">
|
|
<div class="text-center">
|
|
<div class="text-4xl font-bold mb-2" :class="concentrationColor">
|
|
{{ topSourcePct }}%
|
|
</div>
|
|
<div class="text-sm text-neutral-600 mb-3">
|
|
Top source percentage
|
|
</div>
|
|
<ConcentrationChip
|
|
:status="concentrationStatus"
|
|
:top-source-pct="topSourcePct"
|
|
:show-percentage="false"
|
|
variant="solid"
|
|
size="md" />
|
|
</div>
|
|
<p class="text-sm text-neutral-600 text-center">
|
|
Most of your money comes from one place. Add another stream to
|
|
reduce risk.
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium">Payout Delay Exposure</h3>
|
|
</template>
|
|
<div class="space-y-4">
|
|
<div class="text-center">
|
|
<div class="text-4xl font-bold text-yellow-600 mb-2">35 days</div>
|
|
<div class="text-sm text-neutral-600 mb-3">
|
|
Weighted average delay
|
|
</div>
|
|
<UBadge color="yellow" variant="subtle">Moderate Risk</UBadge>
|
|
</div>
|
|
<p class="text-sm text-neutral-600 text-center">
|
|
Money is earned now but arrives later. Delays can create mid-month
|
|
dips.
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<!-- Revenue Streams Table -->
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-medium">Revenue Streams</h3>
|
|
<UButton icon="i-heroicons-plus" size="sm" @click="addStream">
|
|
Add Stream
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
|
|
<UTable :rows="streams" :columns="columns">
|
|
<template #name-data="{ row }">
|
|
<div>
|
|
<div class="font-medium">{{ row.name }}</div>
|
|
<div class="text-xs text-neutral-500">{{ row.category }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #targetPct-data="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<UInput
|
|
v-model="row.targetPct"
|
|
type="number"
|
|
size="xs"
|
|
class="w-16"
|
|
@update:model-value="updateStream(row.id, 'targetPct', $event)" />
|
|
<span class="text-xs text-neutral-500">%</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #targetAmount-data="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-neutral-500">€</span>
|
|
<UInput
|
|
v-model="row.targetMonthlyAmount"
|
|
type="number"
|
|
size="xs"
|
|
class="w-20"
|
|
@update:model-value="
|
|
updateStream(row.id, 'targetMonthlyAmount', $event)
|
|
" />
|
|
</div>
|
|
</template>
|
|
|
|
<template #fees-data="{ row }">
|
|
<div class="text-sm">
|
|
<div v-if="row.platformFeePct > 0">
|
|
Platform: {{ row.platformFeePct }}%
|
|
</div>
|
|
<div v-if="row.revenueSharePct > 0">
|
|
Share: {{ row.revenueSharePct }}%
|
|
</div>
|
|
<div
|
|
v-if="row.platformFeePct === 0 && row.revenueSharePct === 0"
|
|
class="text-neutral-400">
|
|
None
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #delay-data="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<UInput
|
|
v-model="row.payoutDelayDays"
|
|
type="number"
|
|
size="xs"
|
|
class="w-16"
|
|
@update:model-value="
|
|
updateStream(row.id, 'payoutDelayDays', $event)
|
|
" />
|
|
<span class="text-xs text-neutral-500">days</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #restrictions-data="{ row }">
|
|
<RestrictionChip :restriction="row.restrictions" />
|
|
</template>
|
|
|
|
<template #certainty-data="{ row }">
|
|
<UBadge
|
|
:color="getCertaintyColor(row.certainty)"
|
|
variant="subtle"
|
|
size="xs">
|
|
{{ row.certainty }}
|
|
</UBadge>
|
|
</template>
|
|
|
|
<template #actions-data="{ row }">
|
|
<UDropdown :items="getRowActions(row)">
|
|
<UButton
|
|
icon="i-heroicons-ellipsis-horizontal"
|
|
size="xs"
|
|
variant="ghost" />
|
|
</UDropdown>
|
|
</template>
|
|
</UTable>
|
|
|
|
<div class="mt-4 p-4 bg-neutral-50 rounded-lg">
|
|
<div class="flex justify-between text-sm">
|
|
<span class="font-medium">Totals</span>
|
|
<div class="flex gap-6">
|
|
<span>{{ totalTargetPct }}%</span>
|
|
<span>{{ $format.currency(totalMonthlyAmount) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const { $format } = useNuxtApp();
|
|
|
|
// Use real store data instead of fixtures
|
|
const streamsStore = useStreamsStore();
|
|
const { streams } = storeToRefs(streamsStore);
|
|
|
|
const columns = [
|
|
{ id: "name", key: "name", label: "Stream" },
|
|
{ id: "targetPct", key: "targetPct", label: "Target %" },
|
|
{ id: "targetAmount", key: "targetAmount", label: "Monthly €" },
|
|
{ id: "fees", key: "fees", label: "Fees" },
|
|
{ id: "delay", key: "delay", label: "Payout Delay" },
|
|
{ id: "restrictions", key: "restrictions", label: "Use" },
|
|
{ id: "certainty", key: "certainty", label: "Certainty" },
|
|
{ id: "actions", key: "actions", label: "" },
|
|
];
|
|
|
|
const totalTargetPct = computed(() => streamsStore.totalTargetPct);
|
|
const totalMonthlyAmount = computed(() => streamsStore.totalMonthlyAmount);
|
|
|
|
// Calculate concentration metrics
|
|
const topSourcePct = computed(() => {
|
|
if (streams.value.length === 0) return 0;
|
|
const amounts = streams.value.map((s) => s.targetMonthlyAmount || 0);
|
|
return (
|
|
Math.round((Math.max(...amounts) / totalMonthlyAmount.value) * 100) || 0
|
|
);
|
|
});
|
|
|
|
const concentrationStatus = computed(() => {
|
|
if (topSourcePct.value > 50) return "red";
|
|
if (topSourcePct.value > 35) return "yellow";
|
|
return "green";
|
|
});
|
|
|
|
const concentrationColor = computed(() => {
|
|
if (topSourcePct.value > 50) return "text-red-600";
|
|
if (topSourcePct.value > 35) return "text-yellow-600";
|
|
return "text-green-600";
|
|
});
|
|
|
|
function getCertaintyColor(certainty: string) {
|
|
switch (certainty) {
|
|
case "Committed":
|
|
return "green";
|
|
case "Probable":
|
|
return "blue";
|
|
case "Aspirational":
|
|
return "yellow";
|
|
default:
|
|
return "gray";
|
|
}
|
|
}
|
|
|
|
function getRowActions(row: any) {
|
|
return [
|
|
[
|
|
{
|
|
label: "Edit",
|
|
icon: "i-heroicons-pencil",
|
|
click: () => editStream(row),
|
|
},
|
|
{
|
|
label: "Duplicate",
|
|
icon: "i-heroicons-document-duplicate",
|
|
click: () => duplicateStream(row),
|
|
},
|
|
{
|
|
label: "Remove",
|
|
icon: "i-heroicons-trash",
|
|
click: () => removeStream(row),
|
|
},
|
|
],
|
|
];
|
|
}
|
|
|
|
function updateStream(id: string, field: string, value: any) {
|
|
const stream = streams.value.find((s) => s.id === id);
|
|
if (stream) {
|
|
stream[field] = Number(value) || value;
|
|
streamsStore.upsertStream(stream);
|
|
}
|
|
}
|
|
|
|
function addStream() {
|
|
const newStream = {
|
|
id: Date.now().toString(),
|
|
name: "",
|
|
category: "games",
|
|
subcategory: "",
|
|
targetPct: 0,
|
|
targetMonthlyAmount: 0,
|
|
certainty: "Aspirational",
|
|
payoutDelayDays: 30,
|
|
terms: "Net 30",
|
|
revenueSharePct: 0,
|
|
platformFeePct: 0,
|
|
restrictions: "General",
|
|
seasonalityWeights: new Array(12).fill(1),
|
|
effortHoursPerMonth: 0,
|
|
};
|
|
streamsStore.upsertStream(newStream);
|
|
}
|
|
|
|
function editStream(row: any) {
|
|
// Edit stream logic
|
|
console.log("Edit stream", row);
|
|
}
|
|
|
|
function duplicateStream(row: any) {
|
|
// Duplicate stream logic
|
|
console.log("Duplicate stream", row);
|
|
}
|
|
|
|
function removeStream(row: any) {
|
|
streamsStore.removeStream(row.id);
|
|
}
|
|
|
|
function sendToBudget() {
|
|
navigateTo("/budget");
|
|
}
|
|
</script>
|