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

131
stores/budget.ts Normal file
View file

@ -0,0 +1,131 @@
import { defineStore } from "pinia";
export const useBudgetStore = defineStore(
"budget",
() => {
// Schema version for persistence
const schemaVersion = "1.0";
// Monthly budget lines by period (YYYY-MM)
const budgetLines = ref({});
// Overhead costs (recurring monthly)
const overheadCosts = ref([]);
// Production costs (variable monthly)
const productionCosts = ref([]);
// Current selected period
const currentPeriod = ref("2024-01");
// Computed current budget
const currentBudget = computed(() => {
return (
budgetLines.value[currentPeriod.value] || {
period: currentPeriod.value,
revenueByStream: {},
payrollCosts: { memberHours: [], oncostApplied: 0 },
overheadCosts: [],
productionCosts: [],
savingsChange: 0,
net: 0,
}
);
});
// Actions
function setBudgetLine(period, budgetData) {
budgetLines.value[period] = {
period,
...budgetData,
};
}
function updateRevenue(period, streamId, type, amount) {
if (!budgetLines.value[period]) {
budgetLines.value[period] = { period, revenueByStream: {} };
}
if (!budgetLines.value[period].revenueByStream[streamId]) {
budgetLines.value[period].revenueByStream[streamId] = {};
}
budgetLines.value[period].revenueByStream[streamId][type] = amount;
}
// Wizard-required actions
function addOverheadLine(cost) {
// Allow creating a blank line so the user can fill it out in the UI
const safeName = cost?.name ?? "";
const safeAmountMonthly =
typeof cost?.amountMonthly === "number" &&
!Number.isNaN(cost.amountMonthly)
? cost.amountMonthly
: 0;
overheadCosts.value.push({
id: Date.now().toString(),
name: safeName,
amount: safeAmountMonthly,
category: cost?.category || "Operations",
recurring: cost?.recurring ?? true,
...cost,
});
}
function removeOverheadLine(id) {
const index = overheadCosts.value.findIndex((c) => c.id === id);
if (index > -1) {
overheadCosts.value.splice(index, 1);
}
}
function addOverheadCost(cost) {
addOverheadLine({ name: cost.name, amountMonthly: cost.amount, ...cost });
}
function addProductionCost(cost) {
productionCosts.value.push({
id: Date.now().toString(),
name: cost.name,
amount: cost.amount,
category: cost.category || "Production",
period: cost.period,
...cost,
});
}
function setCurrentPeriod(period) {
currentPeriod.value = period;
}
// Reset function
function resetBudgetOverhead() {
overheadCosts.value = [];
productionCosts.value = [];
}
return {
budgetLines,
overheadCosts,
productionCosts,
currentPeriod: readonly(currentPeriod),
currentBudget,
schemaVersion,
setBudgetLine,
updateRevenue,
// Wizard actions
addOverheadLine,
removeOverheadLine,
resetBudgetOverhead,
// Legacy actions
addOverheadCost,
addProductionCost,
setCurrentPeriod,
};
},
{
persist: {
key: "urgent-tools-budget",
paths: ["overheadCosts", "productionCosts", "currentPeriod"],
},
}
);

114
stores/cash.ts Normal file
View file

@ -0,0 +1,114 @@
import { defineStore } from "pinia";
export const useCashStore = defineStore("cash", () => {
// 13-week cash flow events
const cashEvents = ref([]);
// Payment queue - staged payments within policy
const paymentQueue = ref([]);
// Week that first breaches minimum cushion
const firstBreachWeek = ref(null);
// Current cash and savings balances
const currentCash = ref(5000);
const currentSavings = ref(8000);
// Computed weekly projections
const weeklyProjections = computed(() => {
const weeks = [];
let runningBalance = currentCash.value;
for (let week = 1; week <= 13; week++) {
const weekEvents = cashEvents.value.filter((e) => e.week === week);
const weekInflow = weekEvents
.filter((e) => e.type === "Influx")
.reduce((sum, e) => sum + e.amount, 0);
const weekOutflow = weekEvents
.filter((e) => e.type === "Outflow")
.reduce((sum, e) => sum + e.amount, 0);
const net = weekInflow - weekOutflow;
runningBalance += net;
weeks.push({
number: week,
inflow: weekInflow,
outflow: weekOutflow,
net,
balance: runningBalance,
cushion: runningBalance, // Will be calculated properly later
breachesCushion: false, // Will be calculated properly later
});
}
return weeks;
});
// Actions
function addCashEvent(event) {
cashEvents.value.push({
id: Date.now().toString(),
date: event.date,
week: event.week,
type: event.type, // Influx|Outflow
amount: event.amount,
sourceRef: event.sourceRef,
policyTag: event.policyTag, // Payroll|Tax|Vendor|SavingsSweep
...event,
});
}
function updateCashEvent(id, updates) {
const event = cashEvents.value.find((e) => e.id === id);
if (event) {
Object.assign(event, updates);
}
}
function removeCashEvent(id) {
const index = cashEvents.value.findIndex((e) => e.id === id);
if (index > -1) {
cashEvents.value.splice(index, 1);
}
}
function addToPaymentQueue(payment) {
paymentQueue.value.push({
id: Date.now().toString(),
amount: payment.amount,
recipient: payment.recipient,
scheduledWeek: payment.scheduledWeek,
priority: payment.priority,
canStage: payment.canStage !== false,
...payment,
});
}
function stagePayment(paymentId, newWeek) {
const payment = paymentQueue.value.find((p) => p.id === paymentId);
if (payment && payment.canStage) {
payment.scheduledWeek = newWeek;
}
}
function updateCurrentBalances(cash, savings) {
currentCash.value = cash;
currentSavings.value = savings;
}
return {
cashEvents: readonly(cashEvents),
paymentQueue: readonly(paymentQueue),
firstBreachWeek: readonly(firstBreachWeek),
currentCash: readonly(currentCash),
currentSavings: readonly(currentSavings),
weeklyProjections,
addCashEvent,
updateCashEvent,
removeCashEvent,
addToPaymentQueue,
stagePayment,
updateCurrentBalances,
};
});

217
stores/members.ts Normal file
View file

@ -0,0 +1,217 @@
import { defineStore } from "pinia";
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) {
const normalized = {
id: raw.id || Date.now().toString(),
displayName: typeof raw.displayName === "string" ? raw.displayName : "",
roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "",
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),
...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 = [];
}
return {
members,
capacityTotals,
privacyFlags,
validationDetails,
isValid,
schemaVersion,
// Wizard actions
upsertMember,
setCapacity,
setPayRelationship,
setRiskBand,
setExternalCoveragePct,
setPrivacy,
resetMembers,
// Legacy actions
addMember,
updateMember,
removeMember,
};
},
{
persist: {
key: "urgent-tools-members",
paths: ["members", "privacyFlags"],
},
}
);

182
stores/policies.ts Normal file
View file

@ -0,0 +1,182 @@
import { defineStore } from "pinia";
export const usePoliciesStore = defineStore(
"policies",
() => {
// Schema version for persistence
const schemaVersion = "1.0";
// Core policies
const equalHourlyWage = ref(0);
const payrollOncostPct = ref(25);
const savingsTargetMonths = ref(3);
const minCashCushionAmount = ref(3000);
// Deferred pay limits
const deferredCapHoursPerQtr = ref(240);
const deferredSunsetMonths = ref(12);
// Surplus distribution order
const surplusOrder = ref([
"Deferred",
"Savings",
"Hardship",
"Training",
"Patronage",
"Retained",
]);
// Payment priority order
const paymentPriority = ref(["Payroll", "Taxes", "CriticalOps", "Vendors"]);
// Volunteer scope - allowed flows
const volunteerScope = ref({
allowedFlows: ["Care", "SharedLearning"],
});
// Validation computed
const isValid = computed(() => {
return (
equalHourlyWage.value > 0 &&
payrollOncostPct.value >= 0 &&
payrollOncostPct.value <= 100 &&
savingsTargetMonths.value >= 0 &&
minCashCushionAmount.value >= 0 &&
deferredCapHoursPerQtr.value >= 0 &&
deferredSunsetMonths.value >= 0
);
});
// Wizard-required actions
function setEqualWage(amount) {
if (amount > 0) {
equalHourlyWage.value = amount;
}
}
function setOncostPct(pct) {
if (pct >= 0 && pct <= 100) {
payrollOncostPct.value = pct;
}
}
function setSavingsTargetMonths(months) {
if (months >= 0) {
savingsTargetMonths.value = months;
}
}
function setMinCashCushion(amount) {
if (amount >= 0) {
minCashCushionAmount.value = amount;
}
}
function setDeferredCap(hours) {
if (hours >= 0) {
deferredCapHoursPerQtr.value = hours;
}
}
function setDeferredSunset(months) {
if (months >= 0) {
deferredSunsetMonths.value = months;
}
}
function setVolunteerScope(allowedFlows) {
volunteerScope.value = { allowedFlows: [...allowedFlows] };
}
function setSurplusOrder(order) {
surplusOrder.value = [...order];
}
function setPaymentPriority(priority) {
paymentPriority.value = [...priority];
}
// Legacy actions
function updatePolicy(key, value) {
if (key === "equalHourlyWage") setEqualWage(value);
else if (key === "payrollOncostPct") setOncostPct(value);
else if (key === "savingsTargetMonths") setSavingsTargetMonths(value);
else if (key === "minCashCushionAmount") setMinCashCushion(value);
else if (key === "deferredCapHoursPerQtr") setDeferredCap(value);
else if (key === "deferredSunsetMonths") setDeferredSunset(value);
}
function updateSurplusOrder(newOrder) {
setSurplusOrder(newOrder);
}
function updatePaymentPriority(newOrder) {
setPaymentPriority(newOrder);
}
// Reset function
function resetPolicies() {
equalHourlyWage.value = 0;
payrollOncostPct.value = 25;
savingsTargetMonths.value = 3;
minCashCushionAmount.value = 3000;
deferredCapHoursPerQtr.value = 240;
deferredSunsetMonths.value = 12;
surplusOrder.value = [
"Deferred",
"Savings",
"Hardship",
"Training",
"Patronage",
"Retained",
];
paymentPriority.value = ["Payroll", "Taxes", "CriticalOps", "Vendors"];
volunteerScope.value = { allowedFlows: ["Care", "SharedLearning"] };
}
return {
equalHourlyWage,
payrollOncostPct,
savingsTargetMonths,
minCashCushionAmount,
deferredCapHoursPerQtr,
deferredSunsetMonths,
surplusOrder,
paymentPriority,
volunteerScope,
isValid,
schemaVersion,
// Wizard actions
setEqualWage,
setOncostPct,
setSavingsTargetMonths,
setMinCashCushion,
setDeferredCap,
setDeferredSunset,
setVolunteerScope,
setSurplusOrder,
setPaymentPriority,
resetPolicies,
// Legacy actions
updatePolicy,
updateSurplusOrder,
updatePaymentPriority,
};
},
{
persist: {
key: "urgent-tools-policies",
paths: [
"equalHourlyWage",
"payrollOncostPct",
"savingsTargetMonths",
"minCashCushionAmount",
"deferredCapHoursPerQtr",
"deferredSunsetMonths",
"surplusOrder",
"paymentPriority",
"volunteerScope",
],
},
}
);

95
stores/scenarios.ts Normal file
View file

@ -0,0 +1,95 @@
import { defineStore } from "pinia";
export const useScenariosStore = defineStore("scenarios", () => {
// Scenario presets
const presets = ref({
current: {
name: "Operate Current Plan",
description: "Continue with existing revenue and capacity",
settings: {},
},
quitDayJobs: {
name: "Quit Day Jobs",
description: "Members leave external work, increase co-op hours",
settings: {
targetHoursMultiplier: 1.5,
externalCoverageReduction: 0.8,
},
},
startProduction: {
name: "Start Production",
description: "Launch product development phase",
settings: {
productionCostsIncrease: 2000,
effortHoursIncrease: 100,
},
},
sixMonth: {
name: "6-Month Plan",
description: "Extended planning horizon",
settings: {
timeHorizonMonths: 6,
},
},
});
// What-if sliders state
const sliders = ref({
monthlyRevenue: 12000,
paidHoursPerMonth: 320,
winRatePct: 70,
avgPayoutDelayDays: 30,
hourlyWageAdjust: 0,
});
// Selected scenario
const activeScenario = ref("current");
// Computed scenario results (will be calculated by composables)
const scenarioResults = computed(() => ({
runway: 2.8,
monthlyCosts: 8700,
cashflowRisk: "medium",
recommendations: [],
}));
// Actions
function setActiveScenario(scenarioKey) {
activeScenario.value = scenarioKey;
}
function updateSlider(key, value) {
if (key in sliders.value) {
sliders.value[key] = value;
}
}
function resetSliders() {
sliders.value = {
monthlyRevenue: 12000,
paidHoursPerMonth: 320,
winRatePct: 70,
avgPayoutDelayDays: 30,
hourlyWageAdjust: 0,
};
}
function saveCustomScenario(name, settings) {
presets.value[name.toLowerCase().replace(/\s+/g, "")] = {
name,
description: "Custom scenario",
settings,
};
}
return {
presets: readonly(presets),
sliders,
activeScenario: readonly(activeScenario),
scenarioResults,
setActiveScenario,
updateSlider,
resetSliders,
saveCustomScenario,
};
});

132
stores/session.ts Normal file
View file

@ -0,0 +1,132 @@
import { defineStore } from "pinia";
export const useSessionStore = defineStore("session", () => {
// Value Accounting session checklist state
const checklist = ref({
monthClosed: false,
contributionsLogged: false,
surplusCalculated: false,
needsReviewed: false,
});
// Draft distribution allocations
const draftAllocations = ref({
deferredRepay: 0,
savings: 0,
hardship: 0,
training: 0,
patronage: 0,
retained: 0,
});
// Session rationale text
const rationale = ref("");
// Current session period
const currentSession = ref("2024-01");
// Saved distribution records
const savedRecords = ref([]);
// Available amounts for distribution
const availableAmounts = ref({
surplus: 0,
deferredOwed: 0,
savingsNeeded: 0,
});
// Computed total allocated
const totalAllocated = computed(() =>
Object.values(draftAllocations.value).reduce(
(sum, amount) => sum + amount,
0
)
);
// Computed checklist completion
const checklistComplete = computed(() =>
Object.values(checklist.value).every(Boolean)
);
// Actions
function updateChecklistItem(key, value) {
if (key in checklist.value) {
checklist.value[key] = value;
}
}
function updateAllocation(key, amount) {
if (key in draftAllocations.value) {
draftAllocations.value[key] = Number(amount) || 0;
}
}
function resetAllocations() {
Object.keys(draftAllocations.value).forEach((key) => {
draftAllocations.value[key] = 0;
});
}
function updateRationale(text) {
rationale.value = text;
}
function saveSession() {
const record = {
id: Date.now().toString(),
period: currentSession.value,
date: new Date().toISOString(),
allocations: { ...draftAllocations.value },
rationale: rationale.value,
checklist: { ...checklist.value },
};
savedRecords.value.push(record);
// Reset for next session
resetAllocations();
rationale.value = "";
Object.keys(checklist.value).forEach((key) => {
checklist.value[key] = false;
});
return record;
}
function loadSession(period) {
const record = savedRecords.value.find((r) => r.period === period);
if (record) {
currentSession.value = period;
Object.assign(draftAllocations.value, record.allocations);
rationale.value = record.rationale;
Object.assign(checklist.value, record.checklist);
}
}
function setCurrentSession(period) {
currentSession.value = period;
}
function updateAvailableAmounts(amounts) {
Object.assign(availableAmounts.value, amounts);
}
return {
checklist,
draftAllocations,
rationale,
currentSession: readonly(currentSession),
savedRecords: readonly(savedRecords),
availableAmounts: readonly(availableAmounts),
totalAllocated,
checklistComplete,
updateChecklistItem,
updateAllocation,
resetAllocations,
updateRationale,
saveSession,
loadSession,
setCurrentSession,
updateAvailableAmounts,
};
});

117
stores/streams.ts Normal file
View file

@ -0,0 +1,117 @@
import { defineStore } from "pinia";
export const useStreamsStore = defineStore(
"streams",
() => {
// Schema version for persistence
const schemaVersion = "1.0";
// Revenue streams with all properties
const streams = ref([]);
// Computed totals
const totalTargetPct = computed(() =>
streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0)
);
const totalMonthlyAmount = computed(() =>
streams.value.reduce(
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
0
)
);
// Validation computed
const targetPctDeviation = computed(() => {
const total = totalTargetPct.value;
return Math.abs(100 - total);
});
const hasValidStreams = computed(() => {
return streams.value.every(
(stream) =>
stream.name &&
stream.category &&
stream.payoutDelayDays >= 0 &&
(stream.targetPct >= 0 || stream.targetMonthlyAmount >= 0)
);
});
// Wizard-required actions
function upsertStream(stream) {
const existingIndex = streams.value.findIndex((s) => s.id === stream.id);
if (existingIndex > -1) {
streams.value[existingIndex] = {
...streams.value[existingIndex],
...stream,
};
} else {
const newStream = {
id: stream.id || Date.now().toString(),
name: stream.name,
category: stream.category,
subcategory: stream.subcategory || "",
targetPct: stream.targetPct || 0,
targetMonthlyAmount: stream.targetMonthlyAmount || 0,
certainty: stream.certainty || "Aspirational", // Committed|Probable|Aspirational
payoutDelayDays: stream.payoutDelayDays || 0,
terms: stream.terms || "",
revenueSharePct: stream.revenueSharePct || 0,
platformFeePct: stream.platformFeePct || 0,
restrictions: stream.restrictions || "General", // Restricted|General
seasonalityWeights:
stream.seasonalityWeights || new Array(12).fill(1),
effortHoursPerMonth: stream.effortHoursPerMonth || 0,
...stream,
};
streams.value.push(newStream);
}
}
// Legacy actions
function addStream(stream) {
upsertStream(stream);
}
function updateStream(id, updates) {
const stream = streams.value.find((s) => s.id === id);
if (stream) {
Object.assign(stream, updates);
}
}
function removeStream(id) {
const index = streams.value.findIndex((s) => s.id === id);
if (index > -1) {
streams.value.splice(index, 1);
}
}
// Reset function
function resetStreams() {
streams.value = [];
}
return {
streams,
totalTargetPct,
totalMonthlyAmount,
targetPctDeviation,
hasValidStreams,
schemaVersion,
// Wizard actions
upsertStream,
resetStreams,
// Legacy actions
addStream,
updateStream,
removeStream,
};
},
{
persist: {
key: "urgent-tools-streams",
paths: ["streams"],
},
}
);

28
stores/wizard.ts Normal file
View file

@ -0,0 +1,28 @@
import { defineStore } from "pinia";
export const useWizardStore = defineStore(
"wizard",
() => {
const currentStep = ref(1);
function setStep(step: number) {
currentStep.value = Math.min(Math.max(1, step), 5);
}
function reset() {
currentStep.value = 1;
}
return {
currentStep,
setStep,
reset,
};
},
{
persist: {
key: "urgent-tools-wizard",
paths: ["currentStep"],
},
}
);