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

View file

@ -0,0 +1,204 @@
<template>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium mb-4">Operating Costs</h3>
<p class="text-gray-600 mb-6">
Add your monthly overhead costs. Production costs are handled
separately.
</p>
</div>
<!-- Overhead Costs -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="font-medium">Monthly Overhead</h4>
<UButton size="sm" @click="addOverheadCost" icon="i-heroicons-plus">
Add Cost
</UButton>
</div>
<div
v-if="overheadCosts.length === 0"
class="text-center py-8 text-gray-500">
<p>No overhead costs added yet.</p>
<p class="text-sm">
Add costs like rent, tools, insurance, or other recurring expenses.
</p>
</div>
<div
v-for="cost in overheadCosts"
:key="cost.id"
class="p-4 border border-gray-200 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<UFormField label="Cost Name" required>
<UInput
v-model="cost.name"
placeholder="Office rent"
@update:model-value="saveCost(cost)"
@blur="saveCost(cost)" />
</UFormField>
<UFormField label="Monthly Amount" required>
<UInput
v-model.number="cost.amount"
type="number"
min="0"
step="0.01"
placeholder="800.00"
@update:model-value="saveCost(cost)"
@blur="saveCost(cost)">
<template #leading>
<span class="text-gray-500"></span>
</template>
</UInput>
</UFormField>
<UFormField label="Category">
<USelect
v-model="cost.category"
:items="categoryOptions"
@update:model-value="saveCost(cost)" />
</UFormField>
</div>
<div class="flex justify-end mt-4 pt-4 border-t border-gray-100">
<UButton
size="sm"
variant="ghost"
color="red"
@click="removeCost(cost.id)">
Remove
</UButton>
</div>
</div>
</div>
<!-- Cost Categories -->
<div class="bg-blue-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-2 text-blue-900">Cost Categories</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<span class="font-medium text-blue-800">Operations:</span>
<span class="text-blue-700 ml-1">Rent, utilities, insurance</span>
</div>
<div>
<span class="font-medium text-blue-800">Tools:</span>
<span class="text-blue-700 ml-1">Software, hardware, licenses</span>
</div>
<div>
<span class="font-medium text-blue-800">Professional:</span>
<span class="text-blue-700 ml-1">Legal, accounting, consulting</span>
</div>
<div>
<span class="font-medium text-blue-800">Other:</span>
<span class="text-blue-700 ml-1">Miscellaneous costs</span>
</div>
</div>
</div>
<!-- Summary -->
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-2">Cost Summary</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-600">Total items:</span>
<span class="font-medium ml-1">{{ overheadCosts.length }}</span>
</div>
<div>
<span class="text-gray-600">Monthly overhead:</span>
<span class="font-medium ml-1">{{ totalMonthlyCosts }}</span>
</div>
<div>
<span class="text-gray-600">Largest cost:</span>
<span class="font-medium ml-1">{{ largestCost }}</span>
</div>
<div>
<span class="text-gray-600">Avg per item:</span>
<span class="font-medium ml-1">{{ avgCostPerItem }}</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 budgetStore = useBudgetStore();
const { overheadCosts } = storeToRefs(budgetStore);
// Category options
const categoryOptions = [
{ label: "Operations", value: "Operations" },
{ label: "Tools & Software", value: "Tools" },
{ label: "Professional Services", value: "Professional" },
{ label: "Marketing", value: "Marketing" },
{ label: "Other", value: "Other" },
];
// Computeds
const totalMonthlyCosts = computed(() =>
overheadCosts.value.reduce((sum, cost) => sum + (cost.amount || 0), 0)
);
const largestCost = computed(() => {
if (overheadCosts.value.length === 0) return 0;
return Math.max(...overheadCosts.value.map((c) => c.amount || 0));
});
const avgCostPerItem = computed(() => {
if (overheadCosts.value.length === 0) return 0;
return Math.round(totalMonthlyCosts.value / overheadCosts.value.length);
});
// Live-write with debounce
const debouncedSave = useDebounceFn((cost: any) => {
emit("save-status", "saving");
try {
// Find and update existing cost
const existingCost = overheadCosts.value.find((c) => c.id === cost.id);
if (existingCost) {
// Store will handle reactivity through the ref
Object.assign(existingCost, cost);
}
emit("save-status", "saved");
} catch (error) {
console.error("Failed to save cost:", error);
emit("save-status", "error");
}
}, 300);
function saveCost(cost: any) {
if (cost.name && cost.amount >= 0) {
debouncedSave(cost);
}
}
function addOverheadCost() {
const newCost = {
id: Date.now().toString(),
name: "",
amount: 0,
category: "Operations",
recurring: true,
};
budgetStore.addOverheadLine({
name: newCost.name,
amountMonthly: newCost.amount,
category: newCost.category,
});
}
function removeCost(id: string) {
budgetStore.removeOverheadLine(id);
}
</script>