refactor: replace Wizard with CoopBuilder in navigation, enhance budget store structure, and streamline template components for improved user experience
This commit is contained in:
parent
eede87a273
commit
f67b138d95
33 changed files with 4970 additions and 2451 deletions
183
utils/offerToStream.ts
Normal file
183
utils/offerToStream.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import type { Member, Offer } from "~/types/coaching";
|
||||
|
||||
interface StreamRow {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
subcategory: string;
|
||||
targetPct: number;
|
||||
targetMonthlyAmount: number;
|
||||
certainty: "Committed" | "Probable" | "Aspirational";
|
||||
payoutDelayDays: number;
|
||||
terms: string;
|
||||
revenueSharePct: number;
|
||||
platformFeePct: number;
|
||||
restrictions: "Restricted" | "General";
|
||||
seasonalityWeights: number[];
|
||||
effortHoursPerMonth: number;
|
||||
// Extended fields for offer-derived streams
|
||||
unitPrice?: number;
|
||||
unitsPerMonth?: number;
|
||||
feePercent?: number;
|
||||
notes?: string;
|
||||
costBreakdown?: {
|
||||
totalHours: number;
|
||||
totalCost: number;
|
||||
memberCosts: Array<{
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
hours: number;
|
||||
hourlyRate: number;
|
||||
baseCost: number;
|
||||
onCostAmount: number;
|
||||
totalCost: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface OfferToStreamOptions {
|
||||
onCostPercent?: number; // Default 25% on-cost
|
||||
category?: string; // Default category for offer-derived streams
|
||||
}
|
||||
|
||||
export function offerToStream(
|
||||
offer: Offer,
|
||||
members: Member[],
|
||||
options: OfferToStreamOptions = {}
|
||||
): StreamRow {
|
||||
const { onCostPercent = 25, category = "services" } = options;
|
||||
|
||||
// Create member lookup map
|
||||
const memberMap = new Map(members.map(m => [m.id, m]));
|
||||
|
||||
// Determine units per month based on offer type
|
||||
const unitsPerMonth = getUnitsPerMonth(offer);
|
||||
|
||||
// Calculate cost breakdown
|
||||
const costBreakdown = calculateCostBreakdown(offer, memberMap, onCostPercent);
|
||||
|
||||
// Generate terms string
|
||||
const terms = offer.payoutDelayDays <= 15 ? "Net 15" :
|
||||
offer.payoutDelayDays <= 30 ? "Net 30" :
|
||||
offer.payoutDelayDays <= 45 ? "Net 45" :
|
||||
`Net ${offer.payoutDelayDays}`;
|
||||
|
||||
// Calculate monthly amount
|
||||
const targetMonthlyAmount = unitsPerMonth > 0 ? offer.price.baseline * unitsPerMonth : 0;
|
||||
|
||||
return {
|
||||
id: `offer-${offer.id}`,
|
||||
name: offer.name,
|
||||
category,
|
||||
subcategory: inferSubcategory(offer.name),
|
||||
targetPct: 0, // Will be calculated later when all streams are known
|
||||
targetMonthlyAmount,
|
||||
certainty: "Probable" as const, // Offers are more than aspirational but not fully committed
|
||||
payoutDelayDays: offer.payoutDelayDays,
|
||||
terms,
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: "General" as const,
|
||||
seasonalityWeights: new Array(12).fill(1),
|
||||
effortHoursPerMonth: calculateEffortHours(offer),
|
||||
// Extended fields
|
||||
unitPrice: offer.price.baseline,
|
||||
unitsPerMonth,
|
||||
feePercent: 3, // Default 3% processing/platform fee
|
||||
notes: offer.whyThis.join(". "),
|
||||
costBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
export function offersToStreams(
|
||||
offers: Offer[],
|
||||
members: Member[],
|
||||
options: OfferToStreamOptions = {}
|
||||
): StreamRow[] {
|
||||
return offers.map(offer => offerToStream(offer, members, options));
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
function getUnitsPerMonth(offer: Offer): number {
|
||||
const offerName = offer.name.toLowerCase();
|
||||
|
||||
// Clinics and workshops are typically one-time deliverables per month
|
||||
if (offerName.includes("clinic") || offerName.includes("workshop")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Check for specific patterns that indicate recurring work
|
||||
if (offerName.includes("retainer") || offerName.includes("ongoing")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Sprints and other project-based work default to 0 (custom/project basis)
|
||||
return 0;
|
||||
}
|
||||
|
||||
function inferSubcategory(offerName: string): string {
|
||||
const name = offerName.toLowerCase();
|
||||
|
||||
if (name.includes("training") || name.includes("workshop")) {
|
||||
return "Workshops/teaching";
|
||||
}
|
||||
if (name.includes("consulting") || name.includes("review") || name.includes("clinic")) {
|
||||
return "Consulting";
|
||||
}
|
||||
if (name.includes("development") || name.includes("sprint") || name.includes("prototype")) {
|
||||
return "Contract development";
|
||||
}
|
||||
if (name.includes("narrative") || name.includes("writing")) {
|
||||
return "Technical services";
|
||||
}
|
||||
if (name.includes("art") || name.includes("pipeline")) {
|
||||
return "Technical services";
|
||||
}
|
||||
|
||||
return "Contract development"; // Default fallback
|
||||
}
|
||||
|
||||
function calculateEffortHours(offer: Offer): number {
|
||||
// Sum up total hours across all members
|
||||
return offer.hoursByMember.reduce((total, allocation) => total + allocation.hours, 0);
|
||||
}
|
||||
|
||||
function calculateCostBreakdown(
|
||||
offer: Offer,
|
||||
memberMap: Map<string, Member>,
|
||||
onCostPercent: number
|
||||
): StreamRow["costBreakdown"] {
|
||||
const memberCosts = offer.hoursByMember.map(allocation => {
|
||||
const member = memberMap.get(allocation.memberId);
|
||||
if (!member) {
|
||||
throw new Error(`Member not found: ${allocation.memberId}`);
|
||||
}
|
||||
|
||||
const baseCost = allocation.hours * member.hourly;
|
||||
const onCostAmount = baseCost * (onCostPercent / 100);
|
||||
const totalCost = baseCost + onCostAmount;
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
memberName: member.name,
|
||||
hours: allocation.hours,
|
||||
hourlyRate: member.hourly,
|
||||
baseCost,
|
||||
onCostAmount,
|
||||
totalCost
|
||||
};
|
||||
});
|
||||
|
||||
const totalHours = memberCosts.reduce((sum, mc) => sum + mc.hours, 0);
|
||||
const totalCost = memberCosts.reduce((sum, mc) => sum + mc.totalCost, 0);
|
||||
|
||||
return {
|
||||
totalHours,
|
||||
totalCost,
|
||||
memberCosts
|
||||
};
|
||||
}
|
||||
|
||||
// Type export for use in other files
|
||||
export type { StreamRow };
|
||||
Loading…
Add table
Add a link
Reference in a new issue