app/components/WizardRevenueStep.vue

405 lines
12 KiB
Vue

<template>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium mb-4">Revenue Streams</h3>
<p class="text-gray-600 mb-6">
Plan your revenue mix with target percentages and payout terms.
</p>
</div>
<!-- Revenue Streams -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="font-medium">Revenue Sources</h4>
<UButton size="sm" @click="addRevenueStream" icon="i-heroicons-plus">
Add Stream
</UButton>
</div>
<div v-if="streams.length === 0" class="text-center py-8 text-gray-500">
<p>No revenue streams added yet.</p>
<p class="text-sm">
Add streams like client work, grants, sales, or other income sources.
</p>
</div>
<div
v-for="stream in streams"
:key="stream.id"
class="p-4 border border-gray-200 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Basic Info -->
<div class="space-y-4">
<UFormField label="Stream Name" required>
<USelectMenu
v-model="stream.name"
:items="nameOptionsByCategory[stream.category] || []"
placeholder="Select or type a source name"
creatable
searchable
@update:model-value="saveStream(stream)" />
</UFormField>
<UFormField label="Category" required>
<USelect
v-model="stream.category"
:items="categoryOptions"
@update:model-value="saveStream(stream)" />
</UFormField>
<UFormField label="Certainty Level">
<USelect
v-model="stream.certainty"
:items="certaintyOptions"
@update:model-value="saveStream(stream)" />
</UFormField>
</div>
<!-- Financial Details -->
<div class="space-y-4">
<UFormField label="Target %" required>
<UInput
v-model.number="stream.targetPct"
type="number"
min="0"
max="100"
placeholder="40"
@update:model-value="saveStream(stream)"
@blur="saveStream(stream)">
<template #trailing>
<span class="text-gray-500">%</span>
</template>
</UInput>
</UFormField>
<UFormField label="Monthly Target">
<UInput
v-model.number="stream.targetMonthlyAmount"
type="number"
min="0"
step="0.01"
placeholder="5000.00"
@update:model-value="saveStream(stream)"
@blur="saveStream(stream)">
<template #leading>
<span class="text-gray-500"></span>
</template>
</UInput>
</UFormField>
<UFormField
label="Payout Delay"
hint="Days from earning to payment">
<UInput
v-model.number="stream.payoutDelayDays"
type="number"
min="0"
placeholder="30"
@update:model-value="saveStream(stream)"
@blur="saveStream(stream)">
<template #trailing>
<span class="text-gray-500">days</span>
</template>
</UInput>
</UFormField>
</div>
</div>
<!-- Advanced Details (Collapsible) -->
<UAccordion
:items="[
{ label: 'Advanced Settings', slot: 'advanced-' + stream.id },
]"
class="mt-4">
<template #[`advanced-${stream.id}`]>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<UFormField label="Platform Fee %">
<UInput
v-model.number="stream.platformFeePct"
type="number"
min="0"
max="100"
placeholder="3"
@update:model-value="saveStream(stream)"
@blur="saveStream(stream)">
<template #trailing>
<span class="text-gray-500">%</span>
</template>
</UInput>
</UFormField>
<UFormField label="Revenue Share %">
<UInput
v-model.number="stream.revenueSharePct"
type="number"
min="0"
max="100"
placeholder="0"
@update:model-value="saveStream(stream)"
@blur="saveStream(stream)">
<template #trailing>
<span class="text-gray-500">%</span>
</template>
</UInput>
</UFormField>
<UFormField label="Payment Terms">
<UInput
v-model="stream.terms"
placeholder="Net 30"
@update:model-value="saveStream(stream)"
@blur="saveStream(stream)" />
</UFormField>
<UFormField label="Fund Restrictions">
<USelect
v-model="stream.restrictions"
:items="restrictionOptions"
@update:model-value="saveStream(stream)" />
</UFormField>
</div>
</template>
</UAccordion>
<div class="flex justify-end mt-4 pt-4 border-t border-gray-100">
<UButton
size="sm"
variant="ghost"
color="red"
@click="removeStream(stream.id)">
Remove
</UButton>
</div>
</div>
</div>
<!-- Mix Validation -->
<div v-if="streams.length > 0" class="bg-yellow-50 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="w-4 h-4 text-yellow-600" />
<h4 class="font-medium text-sm text-yellow-900">Revenue Mix Status</h4>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span class="text-yellow-700">Total target %:</span>
<span
class="font-medium ml-1"
:class="totalTargetPct === 100 ? 'text-green-700' : 'text-red-700'">
{{ totalTargetPct }}%
</span>
</div>
<div>
<span class="text-yellow-700">Top source:</span>
<span class="font-medium ml-1">{{ topSourcePct }}%</span>
</div>
<div>
<span class="text-yellow-700">Concentration:</span>
<UBadge
:color="concentrationColor"
variant="subtle"
size="xs"
class="ml-1">
{{ concentrationStatus }}
</UBadge>
</div>
</div>
<p v-if="totalTargetPct !== 100" class="text-xs text-yellow-700 mt-2">
Target percentages should add up to 100%. Currently
{{ totalTargetPct > 100 ? "over" : "under" }} by
{{ Math.abs(100 - totalTargetPct) }}%.
</p>
</div>
<!-- Summary -->
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-2">Revenue Summary</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-600">Total streams:</span>
<span class="font-medium ml-1">{{ streams.length }}</span>
</div>
<div>
<span class="text-gray-600">Monthly target:</span>
<span class="font-medium ml-1">{{ totalMonthlyTarget }}</span>
</div>
<div>
<span class="text-gray-600">Avg payout delay:</span>
<span class="font-medium ml-1">{{ avgPayoutDelay }} days</span>
</div>
<div>
<span class="text-gray-600">Committed %:</span>
<span class="font-medium ml-1">{{ committedPercentage }}%</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useDebounceFn } from "@vueuse/core";
import { storeToRefs } from "pinia";
const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
}>();
// Store
const streamsStore = useStreamsStore();
const { streams } = storeToRefs(streamsStore);
// Options
const categoryOptions = [
{ label: "Games & Products", value: "games" },
{ label: "Services & Contracts", value: "services" },
{ label: "Grants & Funding", value: "grants" },
{ label: "Community Support", value: "community" },
{ label: "Partnerships", value: "partnerships" },
{ label: "Investment Income", value: "investment" },
{ label: "In-Kind Contributions", value: "inkind" },
];
// Suggested names per category
const nameOptionsByCategory: Record<string, string[]> = {
games: [
"Direct sales",
"Platform revenue share",
"DLC/expansions",
"Merchandise",
],
services: [
"Contract development",
"Consulting",
"Workshops/teaching",
"Technical services",
],
grants: [
"Government funding",
"Arts council grants",
"Foundation support",
"Research grants",
],
community: [
"Patreon/subscriptions",
"Crowdfunding",
"Donations",
"Mutual aid received",
],
partnerships: [
"Corporate partnerships",
"Academic partnerships",
"Sponsorships",
],
investment: ["Impact investment", "Venture capital", "Loans"],
inkind: [
"Office space",
"Equipment/hardware",
"Software licenses",
"Professional services",
"Marketing/PR services",
"Legal services",
],
};
const certaintyOptions = [
{ label: "Committed", value: "Committed" },
{ label: "Probable", value: "Probable" },
{ label: "Aspirational", value: "Aspirational" },
];
const restrictionOptions = [
{ label: "General Use", value: "General" },
{ label: "Restricted Use", value: "Restricted" },
];
// Computeds
const totalTargetPct = computed(() => streamsStore.totalTargetPct);
const totalMonthlyTarget = computed(() =>
Math.round(
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
)
);
const topSourcePct = computed(() => {
if (streams.value.length === 0) return 0;
return Math.max(...streams.value.map((s) => s.targetPct || 0));
});
const concentrationStatus = computed(() => {
const topPct = topSourcePct.value;
if (topPct > 50) return "High Risk";
if (topPct > 35) return "Medium Risk";
return "Low Risk";
});
const concentrationColor = computed(() => {
const status = concentrationStatus.value;
if (status === "High Risk") return "red";
if (status === "Medium Risk") return "yellow";
return "green";
});
const avgPayoutDelay = computed(() => {
if (streams.value.length === 0) return 0;
const total = streams.value.reduce(
(sum, s) => sum + (s.payoutDelayDays || 0),
0
);
return Math.round(total / streams.value.length);
});
const committedPercentage = computed(() => {
const committedStreams = streams.value.filter(
(s) => s.certainty === "Committed"
);
const committedPct = committedStreams.reduce(
(sum, s) => sum + (s.targetPct || 0),
0
);
return Math.round(committedPct);
});
// Live-write with debounce
const debouncedSave = useDebounceFn((stream: any) => {
emit("save-status", "saving");
try {
streamsStore.upsertStream(stream);
emit("save-status", "saved");
} catch (error) {
console.error("Failed to save stream:", error);
emit("save-status", "error");
}
}, 300);
function saveStream(stream: any) {
if (stream.name && stream.category && stream.targetPct >= 0) {
debouncedSave(stream);
}
}
function addRevenueStream() {
const newStream = {
id: Date.now().toString(),
name: "",
category: "games",
subcategory: "",
targetPct: 0,
targetMonthlyAmount: 0,
certainty: "Aspirational",
payoutDelayDays: 0,
terms: "",
revenueSharePct: 0,
platformFeePct: 0,
restrictions: "General",
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
};
streamsStore.upsertStream(newStream);
}
function removeStream(id: string) {
streamsStore.removeStream(id);
}
</script>