app/stores/members.ts

273 lines
8.2 KiB
TypeScript

import { defineStore } from "pinia";
import { ref, computed } from 'vue';
import { coverage, teamCoverageStats } from "~/types/members";
export const useMembersStore = defineStore(
"members",
() => {
// Member list and profiles
const members = ref([]);
// Schema version for persistence
const schemaVersion = "1.0";
// Capacity totals across all members
const capacityTotals = computed(() => ({
minHours: members.value.reduce(
(sum, m) => sum + (m.capacity?.minHours || 0),
0
),
targetHours: members.value.reduce(
(sum, m) => sum + (m.capacity?.targetHours || 0),
0
),
maxHours: members.value.reduce(
(sum, m) => sum + (m.capacity?.maxHours || 0),
0
),
}));
// Privacy flags - aggregate vs individual visibility
const privacyFlags = ref({
showIndividualNeeds: false,
showIndividualCapacity: false,
stewardOnlyDetails: true,
});
// Normalize a member object to ensure required structure and sane defaults
function normalizeMember(raw) {
// Calculate hoursPerWeek from targetHours (monthly) if not explicitly set
const targetHours = Number(raw.capacity?.targetHours) || 0;
const hoursPerWeek = raw.hoursPerWeek ?? (targetHours > 0 ? targetHours / 4.33 : 0);
const normalized = {
id: raw.id || Date.now().toString(),
displayName: typeof raw.displayName === "string" ? raw.displayName : "",
hoursPerWeek: hoursPerWeek,
payRelationship: raw.payRelationship || "FullyPaid",
capacity: {
minHours: Number(raw.capacity?.minHours) || 0,
targetHours: Number(raw.capacity?.targetHours) || 0,
maxHours: Number(raw.capacity?.maxHours) || 0,
},
riskBand: raw.riskBand || "Medium",
externalCoveragePct: Number(raw.externalCoveragePct ?? 0),
privacyNeeds: raw.privacyNeeds || "aggregate_ok",
deferredHours: Number(raw.deferredHours ?? 0),
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
// Simplified - only minimum needs for allocation
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
...raw,
};
return normalized;
}
// Initialize normalization for any persisted members
if (Array.isArray(members.value) && members.value.length) {
members.value = members.value.map(normalizeMember);
}
// Validation details for debugging
const validationDetails = computed(() =>
members.value.map((m) => {
const nameOk =
typeof m.displayName === "string" && m.displayName.trim().length > 0;
const relOk = Boolean(m.payRelationship);
const target = Number(m.capacity?.targetHours);
const targetOk = Number.isFinite(target) && target > 0;
const pctRaw = m.externalCoveragePct;
const pct =
pctRaw === undefined || pctRaw === null ? 0 : Number(pctRaw);
const pctOk =
pctRaw === undefined ||
pctRaw === null ||
(Number.isFinite(pct) && pct >= 0 && pct <= 100);
return {
id: m.id,
displayName: m.displayName,
payRelationship: m.payRelationship,
targetHours: m.capacity?.targetHours,
externalCoveragePct: m.externalCoveragePct,
checks: { nameOk, relOk, targetOk, pctOk },
valid: nameOk && relOk && targetOk && pctOk,
};
})
);
// Validation computed (robust against NaN/empty values)
const isValid = computed(() => {
// A member is valid if core required fields pass
const isMemberValid = (m: any) => {
const nameOk =
typeof m.displayName === "string" && m.displayName.trim().length > 0;
const relOk = Boolean(m.payRelationship);
const target = Number(m.capacity?.targetHours);
const targetOk = Number.isFinite(target) && target > 0;
// External coverage is optional; when present it must be within 0..100
const pctRaw = m.externalCoveragePct;
const pct =
pctRaw === undefined || pctRaw === null ? 0 : Number(pctRaw);
const pctOk =
pctRaw === undefined ||
pctRaw === null ||
(Number.isFinite(pct) && pct >= 0 && pct <= 100);
return nameOk && relOk && targetOk && pctOk;
};
// Require at least one valid member
return members.value.some(isMemberValid);
});
// Wizard-required actions
function upsertMember(member) {
const existingIndex = members.value.findIndex((m) => m.id === member.id);
if (existingIndex > -1) {
members.value[existingIndex] = normalizeMember({
...members.value[existingIndex],
...member,
});
} else {
members.value.push(
normalizeMember({
id: member.id || Date.now().toString(),
...member,
})
);
}
}
function setCapacity(memberId, capacity) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.capacity = { ...member.capacity, ...capacity };
}
}
function setPayRelationship(memberId, payRelationship) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.payRelationship = payRelationship;
}
}
function setRiskBand(memberId, riskBand) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.riskBand = riskBand;
}
}
function setExternalCoveragePct(memberId, pct) {
const member = members.value.find((m) => m.id === memberId);
if (member && pct >= 0 && pct <= 100) {
member.externalCoveragePct = pct;
}
}
function setPrivacy(memberId, privacyNeeds) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.privacyNeeds = privacyNeeds;
}
}
// Actions
function addMember(member) {
upsertMember(member);
}
function updateMember(id, updates) {
const member = members.value.find((m) => m.id === id);
if (member) {
Object.assign(member, updates);
}
}
function removeMember(id) {
const index = members.value.findIndex((m) => m.id === id);
if (index > -1) {
members.value.splice(index, 1);
}
}
// Reset function
function resetMembers() {
members.value = [];
}
// Coverage calculations for individual members
function getMemberCoverage(memberId) {
const member = members.value.find((m) => m.id === memberId);
if (!member) return { coveragePct: undefined };
return coverage(
member.minMonthlyNeeds || 0,
member.monthlyPayPlanned || 0
);
}
// Team-wide coverage statistics
const teamStats = computed(() => teamCoverageStats(members.value));
// Pay policy configuration
const payPolicy = ref({
relationship: 'equal-pay' as const,
notes: '',
equalBase: 0,
needsWeight: 0.5,
hoursRate: 0,
customFormula: ''
});
// Setter for minimum needs only
function setMonthlyNeeds(memberId, minNeeds) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.minMonthlyNeeds = Number(minNeeds) || 0;
}
}
// Removed setExternalIncome - no longer needed
function setPlannedPay(memberId, planned) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.monthlyPayPlanned = Number(planned) || 0;
}
}
return {
members,
capacityTotals,
privacyFlags,
validationDetails,
isValid,
schemaVersion,
payPolicy,
teamStats,
// Wizard actions
upsertMember,
setCapacity,
setPayRelationship,
setRiskBand,
setExternalCoveragePct,
setPrivacy,
resetMembers,
// New coverage actions
setMonthlyNeeds,
setPlannedPay,
getMemberCoverage,
// Legacy actions
addMember,
updateMember,
removeMember,
};
},
{
persist: {
key: "urgent-tools-members",
paths: ["members", "privacyFlags", "payPolicy"],
},
}
);