chore: update application configuration and UI components for improved styling and functionality
This commit is contained in:
parent
0af6b17792
commit
37ab8d7bab
54 changed files with 23293 additions and 1666 deletions
|
|
@ -1,237 +1,116 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<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
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-2xl font-black text-black">
|
||||
Where will your money come from?
|
||||
</h3>
|
||||
<UButton
|
||||
v-if="streams.length > 0"
|
||||
@click="addRevenueStream"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
}">
|
||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||||
Add stream
|
||||
</UButton>
|
||||
</div>
|
||||
</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.
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="streams.length === 0"
|
||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">
|
||||
No revenue streams yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
Add sources like client work, grants, product sales, or donations.
|
||||
</p>
|
||||
<UButton
|
||||
@click="addRevenueStream"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<UIcon name="i-heroicons-plus" class="mr-2" />
|
||||
Add your first revenue stream
|
||||
</UButton>
|
||||
</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>
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<UFormField label="Category" required>
|
||||
<USelect
|
||||
v-model="stream.category"
|
||||
:items="categoryOptions"
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@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="Revenue source name" required>
|
||||
<USelectMenu
|
||||
v-model="stream.name"
|
||||
:items="nameOptionsByCategory[stream.category] || []"
|
||||
placeholder="Select or type a source name"
|
||||
creatable
|
||||
searchable
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@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>
|
||||
<UFormField label="Monthly amount" required>
|
||||
<UInput
|
||||
v-model="stream.targetMonthlyAmount"
|
||||
type="text"
|
||||
placeholder="5000"
|
||||
size="xl"
|
||||
class="text-xl font-black w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, stream)"
|
||||
@blur="saveStream(stream)">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500 text-xl">$</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
</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">
|
||||
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
@click="removeStream(stream.id)">
|
||||
Remove
|
||||
size="xs"
|
||||
variant="solid"
|
||||
color="error"
|
||||
@click="removeStream(stream.id)"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||
}">
|
||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||
</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>
|
||||
<!-- Add Stream Button (when items exist) -->
|
||||
<div v-if="streams.length > 0" class="flex justify-center">
|
||||
<UButton
|
||||
@click="addRevenueStream"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
}">
|
||||
<UIcon name="i-heroicons-plus" class="mr-2" />
|
||||
Add another stream
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -240,6 +119,7 @@
|
|||
<script setup lang="ts">
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
|
@ -248,7 +128,7 @@ const emit = defineEmits<{
|
|||
const streamsStore = useStreamsStore();
|
||||
const { streams } = storeToRefs(streamsStore);
|
||||
|
||||
// Options
|
||||
// Original category options
|
||||
const categoryOptions = [
|
||||
{ label: "Games & Products", value: "games" },
|
||||
{ label: "Services & Contracts", value: "services" },
|
||||
|
|
@ -259,7 +139,7 @@ const categoryOptions = [
|
|||
{ label: "In-Kind Contributions", value: "inkind" },
|
||||
];
|
||||
|
||||
// Suggested names per category
|
||||
// Suggested names per category (subcategories)
|
||||
const nameOptionsByCategory: Record<string, string[]> = {
|
||||
games: [
|
||||
"Direct sales",
|
||||
|
|
@ -301,69 +181,27 @@ const nameOptionsByCategory: Record<string, string[]> = {
|
|||
],
|
||||
};
|
||||
|
||||
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)
|
||||
)
|
||||
// Computed
|
||||
const totalMonthlyAmount = computed(() =>
|
||||
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 {
|
||||
// Set sensible defaults for hidden fields
|
||||
stream.targetPct = 0; // Will be calculated automatically later
|
||||
stream.certainty = "Aspirational";
|
||||
stream.payoutDelayDays = 30; // Default 30 days
|
||||
stream.terms = "Net 30";
|
||||
stream.revenueSharePct = 0;
|
||||
stream.platformFeePct = 0;
|
||||
stream.restrictions = "General";
|
||||
stream.seasonalityWeights = new Array(12).fill(1);
|
||||
stream.effortHoursPerMonth = 0;
|
||||
|
||||
streamsStore.upsertStream(stream);
|
||||
emit("save-status", "saved");
|
||||
} catch (error) {
|
||||
|
|
@ -373,11 +211,18 @@ const debouncedSave = useDebounceFn((stream: any) => {
|
|||
}, 300);
|
||||
|
||||
function saveStream(stream: any) {
|
||||
if (stream.name && stream.category && stream.targetPct >= 0) {
|
||||
if (stream.name && stream.category && stream.targetMonthlyAmount >= 0) {
|
||||
debouncedSave(stream);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation function for amount
|
||||
function validateAndSaveAmount(value: string, stream: any) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
stream.targetMonthlyAmount = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
saveStream(stream);
|
||||
}
|
||||
|
||||
function addRevenueStream() {
|
||||
const newStream = {
|
||||
id: Date.now().toString(),
|
||||
|
|
@ -387,8 +232,8 @@ function addRevenueStream() {
|
|||
targetPct: 0,
|
||||
targetMonthlyAmount: 0,
|
||||
certainty: "Aspirational",
|
||||
payoutDelayDays: 0,
|
||||
terms: "",
|
||||
payoutDelayDays: 30,
|
||||
terms: "Net 30",
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: "General",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue