feat: add initial application structure with configuration, UI components, and state management
This commit is contained in:
parent
fadf94002c
commit
0af6b17792
56 changed files with 6137 additions and 129 deletions
196
components/WizardMembersStep.vue
Normal file
196
components/WizardMembersStep.vue
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium mb-4">Members</h3>
|
||||
<p class="text-gray-600 mb-6">Add co-op members and their capacity.</p>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(member, index) in members"
|
||||
:key="member.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="Display Name" required>
|
||||
<UInput
|
||||
v-model="member.displayName"
|
||||
placeholder="Alex Chen"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Role Focus">
|
||||
<UInput
|
||||
v-model="member.roleFocus"
|
||||
placeholder="Technical Lead"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Pay Relationship" required>
|
||||
<USelect
|
||||
v-model="member.payRelationship"
|
||||
:items="payRelationshipOptions"
|
||||
@update:model-value="saveMember(member)" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Capacity & Settings -->
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Target Hours/Month" required>
|
||||
<UInput
|
||||
v-model.number="member.capacity.targetHours"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="120"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="External Coverage %">
|
||||
<UInput
|
||||
v-model.number="member.externalCoveragePct"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="60"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Risk Band">
|
||||
<USelect
|
||||
v-model="member.riskBand"
|
||||
:items="riskBandOptions"
|
||||
@update:model-value="saveMember(member)" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end mt-4 pt-4 border-t border-gray-100">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
@click="removeMember(member.id)">
|
||||
Remove
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Member -->
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="addMember"
|
||||
class="w-full"
|
||||
icon="i-heroicons-plus">
|
||||
Add Member
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-sm mb-2">Capacity Summary</h4>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600">Members:</span>
|
||||
<span class="font-medium ml-1">{{ members.length }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Total Hours:</span>
|
||||
<span class="font-medium ml-1">{{ totalTargetHours }}h</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Avg External:</span>
|
||||
<span class="font-medium ml-1">{{ avgExternalCoverage }}%</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 membersStore = useMembersStore();
|
||||
const { members } = storeToRefs(membersStore);
|
||||
|
||||
// Options
|
||||
const payRelationshipOptions = [
|
||||
{ label: "Fully Paid", value: "FullyPaid" },
|
||||
{ label: "Hybrid", value: "Hybrid" },
|
||||
{ label: "Supplemental", value: "Supplemental" },
|
||||
{ label: "Volunteer/Deferred", value: "VolunteerOrDeferred" },
|
||||
];
|
||||
|
||||
const riskBandOptions = [
|
||||
{ label: "Low Risk", value: "Low" },
|
||||
{ label: "Medium Risk", value: "Medium" },
|
||||
{ label: "High Risk", value: "High" },
|
||||
];
|
||||
|
||||
// Computeds
|
||||
const totalTargetHours = computed(() =>
|
||||
members.value.reduce((sum, m) => sum + (m.capacity?.targetHours || 0), 0)
|
||||
);
|
||||
|
||||
const avgExternalCoverage = computed(() => {
|
||||
if (members.value.length === 0) return 0;
|
||||
const total = members.value.reduce(
|
||||
(sum, m) => sum + (m.externalCoveragePct || 0),
|
||||
0
|
||||
);
|
||||
return Math.round(total / members.value.length);
|
||||
});
|
||||
|
||||
// Live-write with debounce
|
||||
const debouncedSave = useDebounceFn((member: any) => {
|
||||
emit("save-status", "saving");
|
||||
|
||||
try {
|
||||
membersStore.upsertMember(member);
|
||||
emit("save-status", "saved");
|
||||
} catch (error) {
|
||||
console.error("Failed to save member:", error);
|
||||
emit("save-status", "error");
|
||||
}
|
||||
}, 300);
|
||||
|
||||
function saveMember(member: any) {
|
||||
debouncedSave(member);
|
||||
}
|
||||
|
||||
function addMember() {
|
||||
const newMember = {
|
||||
id: Date.now().toString(),
|
||||
displayName: "",
|
||||
roleFocus: "",
|
||||
payRelationship: "FullyPaid",
|
||||
capacity: {
|
||||
minHours: 0,
|
||||
targetHours: 0,
|
||||
maxHours: 0,
|
||||
},
|
||||
riskBand: "Medium",
|
||||
externalCoveragePct: 0,
|
||||
privacyNeeds: "aggregate_ok",
|
||||
deferredHours: 0,
|
||||
quarterlyDeferredCap: 240,
|
||||
};
|
||||
|
||||
membersStore.upsertMember(newMember);
|
||||
}
|
||||
|
||||
function removeMember(id: string) {
|
||||
membersStore.removeMember(id);
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue