183 lines
No EOL
5.2 KiB
TypeScript
183 lines
No EOL
5.2 KiB
TypeScript
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 }; |