app/pages/mix.vue

345 lines
10 KiB
Vue

<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
<div v-if="isSetupComplete" class="flex items-center gap-2 mt-1">
<UBadge color="green" variant="subtle" size="xs">
Synchronized with Setup
</UBadge>
<UButton variant="ghost" size="xs" @click="goToSetup">
Edit in Setup
</UButton>
</div>
</div>
<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 synchronized store data - setup is the source of truth
const { initSync, getStreams, unifiedStreams } = useStoreSync();
const { isSetupComplete, goToSetup } = useSetupState();
const streamsStore = useStreamsStore();
const coopStore = useCoopBuilderStore();
// Initialize synchronization on mount
onMounted(async () => {
await initSync();
});
// Use reactive synchronized streams data
const streams = unifiedStreams;
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(() => {
// Calculate from the unified streams data
return streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0);
});
const totalMonthlyAmount = computed(() => {
// Calculate from the unified streams data
return streams.value.reduce((sum, stream) => sum + (stream.targetMonthlyAmount || stream.monthly || 0), 0);
});
// 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) {
// Update the primary CoopBuilder store (source of truth)
const coopStream = coopStore.streams.find((s) => s.id === id);
if (coopStream) {
if (field === 'targetMonthlyAmount') {
coopStream.monthly = Number(value) || 0;
} else {
coopStream[field] = Number(value) || value;
}
coopStore.upsertStream(coopStream);
}
// Also update the legacy store for backward compatibility
const legacyStream = streams.value.find((s) => s.id === id);
if (legacyStream) {
legacyStream[field] = Number(value) || value;
streamsStore.upsertStream(legacyStream);
}
}
function addStream() {
const newStreamId = Date.now().toString();
// Add to CoopBuilder store first (primary source)
const coopStream = {
id: newStreamId,
label: "",
monthly: 0,
category: "games",
certainty: "Aspirational",
};
coopStore.upsertStream(coopStream);
// Add to legacy store for compatibility
const legacyStream = {
id: newStreamId,
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(legacyStream);
}
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) {
// Remove from both stores to maintain sync
coopStore.removeStream(row.id);
streamsStore.removeStream(row.id);
}
function sendToBudget() {
navigateTo("/budget");
}
</script>