287 lines
8.8 KiB
TypeScript
287 lines
8.8 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 : "",
|
|
roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "",
|
|
role: raw.role || raw.roleFocus || "",
|
|
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),
|
|
// NEW fields for needs coverage
|
|
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
|
|
targetMonthlyPay: Number(raw.targetMonthlyPay) || 0,
|
|
externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 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 { minPct: undefined, targetPct: undefined };
|
|
|
|
return coverage(
|
|
member.minMonthlyNeeds || 0,
|
|
member.targetMonthlyPay || 0,
|
|
member.monthlyPayPlanned || 0,
|
|
member.externalMonthlyIncome || 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,
|
|
roleBands: {},
|
|
hoursRate: 0,
|
|
customFormula: ''
|
|
});
|
|
|
|
// Setters for new fields
|
|
function setMonthlyNeeds(memberId, minNeeds, targetPay) {
|
|
const member = members.value.find((m) => m.id === memberId);
|
|
if (member) {
|
|
member.minMonthlyNeeds = Number(minNeeds) || 0;
|
|
member.targetMonthlyPay = Number(targetPay) || 0;
|
|
}
|
|
}
|
|
|
|
function setExternalIncome(memberId, income) {
|
|
const member = members.value.find((m) => m.id === memberId);
|
|
if (member) {
|
|
member.externalMonthlyIncome = Number(income) || 0;
|
|
}
|
|
}
|
|
|
|
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,
|
|
setExternalIncome,
|
|
setPlannedPay,
|
|
getMemberCoverage,
|
|
// Legacy actions
|
|
addMember,
|
|
updateMember,
|
|
removeMember,
|
|
};
|
|
},
|
|
{
|
|
persist: {
|
|
key: "urgent-tools-members",
|
|
paths: ["members", "privacyFlags", "payPolicy"],
|
|
},
|
|
}
|
|
);
|