feat: add initial application structure with configuration, UI components, and state management

This commit is contained in:
Jennie Robinson Faber 2025-08-09 18:13:16 +01:00
parent fadf94002c
commit 0af6b17792
56 changed files with 6137 additions and 129 deletions

273
pages/mix.vue Normal file
View file

@ -0,0 +1,273 @@
<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 text-red-600 mb-2">65%</div>
<div class="text-sm text-gray-600 mb-3">Top source percentage</div>
<ConcentrationChip
status="red"
:top-source-pct="65"
:show-percentage="false"
variant="solid"
size="md" />
</div>
<p class="text-sm text-gray-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-gray-600 mb-3">Weighted average delay</div>
<UBadge color="yellow" variant="subtle">Moderate Risk</UBadge>
</div>
<p class="text-sm text-gray-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-gray-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-gray-500">%</span>
</div>
</template>
<template #targetAmount-data="{ row }">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-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-gray-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-gray-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-gray-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();
const { loadStreams } = useFixtures();
// Load fixture data
const fixtureData = await loadStreams();
const streams = ref(
fixtureData.revenueStreams.map((stream) => ({
id: stream.id,
name: stream.name,
category: stream.category,
targetPct: stream.targetPct,
targetMonthlyAmount: stream.targetMonthlyAmount,
certainty: stream.certainty,
payoutDelayDays: stream.payoutDelayDays,
platformFeePct: stream.platformFeePct || 0,
revenueSharePct: stream.revenueSharePct || 0,
restrictions: stream.restrictions,
}))
);
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(() =>
streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0)
);
const totalMonthlyAmount = computed(() =>
streams.value.reduce(
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
0
)
);
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;
}
}
function addStream() {
// Add stream logic
console.log("Add new stream");
}
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) {
const index = streams.value.findIndex((s) => s.id === row.id);
if (index > -1) {
streams.value.splice(index, 1);
}
}
function sendToBudget() {
navigateTo("/budget");
}
</script>