196 lines
5.5 KiB
Vue
196 lines
5.5 KiB
Vue
<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>
|