app/composables/useOfferSuggestor.ts

256 lines
No EOL
8.5 KiB
TypeScript

import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { offerTemplates, matchTemplateToInput, getTemplateHours } from '~/data/offerTemplates';
interface SuggestOffersInput {
members: Member[];
selectedSkillsByMember: Record<string, string[]>;
selectedProblems: string[];
}
interface Catalogs {
skills: SkillTag[];
problems: ProblemTag[];
}
export interface OfferTemplate {
id: string;
name: string;
type: 'workshop' | 'clinic' | 'sprint' | 'retainer';
skillRequirements: string[];
problemTargets: string[];
scope: string[];
defaultDays: number;
defaultHours?: Record<string, number>;
whyThisTemplate: string[];
riskTemplate: string;
}
export function useOfferSuggestor() {
// Payout delay defaults by offer type
const payoutDelays = {
workshop: 14,
clinic: 30,
sprint: 45,
retainer: 30
};
function suggestOffers(input: SuggestOffersInput, catalogs: Catalogs): Offer[] {
const { members, selectedSkillsByMember, selectedProblems } = input;
// Get all selected skills across all members
const allSelectedSkills = new Set<string>();
Object.values(selectedSkillsByMember).forEach(skills => {
skills.forEach(skill => allSelectedSkills.add(skill));
});
const selectedSkillsArray = Array.from(allSelectedSkills);
const offers: Offer[] = [];
// Try to match templates
const matchingTemplates = findMatchingTemplates(selectedSkillsArray, selectedProblems);
// Generate offers from matching templates (max 2)
for (const template of matchingTemplates.slice(0, 2)) {
const offer = generateOfferFromTemplate(template, input);
if (offer) {
offers.push(offer);
}
}
// If we have fewer than 2 offers, add default "1-Week Dev Sprint"
if (offers.length < 2) {
const devSprintOffer = generateDevSprintOffer(input);
if (devSprintOffer) {
offers.push(devSprintOffer);
}
}
// If no template matches at all, ensure we always have at least the dev sprint
if (offers.length === 0) {
const devSprintOffer = generateDevSprintOffer(input);
if (devSprintOffer) {
offers.push(devSprintOffer);
}
}
// Limit to 3 offers max
return offers.slice(0, 3);
}
function findMatchingTemplates(selectedSkills: string[], selectedProblems: string[]): OfferTemplate[] {
// Use the lightweight matching rules from the data file
return matchTemplateToInput(selectedSkills, selectedProblems);
}
function generateOfferFromTemplate(template: OfferTemplate, input: SuggestOffersInput): Offer | null {
const { members, selectedSkillsByMember } = input;
// Create skill-to-member mapping
const skillToMemberMap = new Map<string, Member[]>();
members.forEach(member => {
const memberSkills = selectedSkillsByMember[member.id] || [];
memberSkills.forEach(skill => {
if (!skillToMemberMap.has(skill)) {
skillToMemberMap.set(skill, []);
}
skillToMemberMap.get(skill)!.push(member);
});
});
// Generate hours allocation based on template's defaultHours
const hoursByMember: Array<{ memberId: string; hours: number }> = [];
if (template.defaultHours) {
// Use specific hour allocations from template
Object.entries(template.defaultHours).forEach(([skill, hours]) => {
const availableMembers = skillToMemberMap.get(skill) || [];
if (availableMembers.length > 0) {
// Assign to member with highest availability for this skill
const bestMember = availableMembers.sort((a, b) => b.availableHrs - a.availableHrs)[0];
// Check if this member already has hours assigned
const existingAllocation = hoursByMember.find(h => h.memberId === bestMember.id);
if (existingAllocation) {
existingAllocation.hours += hours;
} else {
hoursByMember.push({ memberId: bestMember.id, hours });
}
}
});
} else {
// Fallback to old method if no defaultHours specified
const relevantMembers = members.filter(member => {
const memberSkills = selectedSkillsByMember[member.id] || [];
return template.skillRequirements.some(skill => memberSkills.includes(skill));
});
if (relevantMembers.length === 0) return null;
const totalHours = template.defaultDays * 8; // 8 hours per day
hoursByMember.push(...distributeHours(relevantMembers, totalHours));
}
if (hoursByMember.length === 0) return null;
// Calculate pricing
const pricing = calculatePricing(hoursByMember, members);
return {
id: `${template.id}-${Date.now()}`,
name: `${template.name} (${template.defaultDays} ${template.defaultDays === 1 ? 'day' : 'days'})`,
scope: template.scope,
hoursByMember,
price: {
baseline: pricing.baseline,
stretch: pricing.stretch,
calcNote: pricing.calcNote
},
payoutDelayDays: payoutDelays[template.type],
whyThis: template.whyThisTemplate,
riskNotes: [template.riskTemplate]
};
}
function generateDevSprintOffer(input: SuggestOffersInput): Offer | null {
const { members, selectedSkillsByMember } = input;
// Find members with highest availability (if no skills selected, use all members)
const membersWithSkills = members
.filter(member => (selectedSkillsByMember[member.id] || []).length > 0);
const availableMembers = membersWithSkills.length > 0
? membersWithSkills.sort((a, b) => b.availableHrs - a.availableHrs)
: members.sort((a, b) => b.availableHrs - a.availableHrs);
if (availableMembers.length === 0) return null;
// Use top 2-3 highest availability members
const selectedMembers = availableMembers.slice(0, Math.min(3, availableMembers.length));
// 1 week = 40 hours, distributed among selected members
const hoursByMember = distributeHours(selectedMembers, 40);
const pricing = calculatePricing(hoursByMember, members);
// Get selected skill names for why this
const allSelectedSkills = new Set<string>();
Object.values(selectedSkillsByMember).forEach(skills => {
skills.forEach(skill => allSelectedSkills.add(skill));
});
return {
id: `dev-sprint-${Date.now()}`,
name: 'Development Sprint (1 week)',
scope: [
'Implement specific features or fixes',
'Code review and quality assurance',
'Documentation and handoff'
],
hoursByMember,
price: {
baseline: pricing.baseline,
stretch: pricing.stretch,
calcNote: pricing.calcNote
},
payoutDelayDays: payoutDelays.sprint,
whyThis: [
`Leverages your ${Array.from(allSelectedSkills).slice(0, 2).join(' and ')} skills`,
'Time-boxed sprint reduces risk',
'Fits within your available capacity'
],
riskNotes: ['Scope creep is common in open-ended development work']
};
}
function distributeHours(members: Member[], totalHours: number): Array<{ memberId: string; hours: number }> {
if (members.length === 0) return [];
// Simple distribution based on availability
const totalAvailability = members.reduce((sum, m) => sum + m.availableHrs, 0);
return members.map(member => {
const proportion = member.availableHrs / totalAvailability;
const allocatedHours = Math.round(totalHours * proportion);
return {
memberId: member.id,
hours: Math.min(allocatedHours, member.availableHrs)
};
});
}
function calculatePricing(
hoursByMember: Array<{ memberId: string; hours: number }>,
members: Member[]
): { baseline: number; stretch: number; calcNote: string } {
// Create member lookup
const memberMap = new Map(members.map(m => [m.id, m]));
// Calculate cost-plus pricing
let totalCost = 0;
let totalHours = 0;
for (const allocation of hoursByMember) {
const member = memberMap.get(allocation.memberId);
if (member) {
// memberHours * hourly * 1.25 (markup)
totalCost += allocation.hours * member.hourly * 1.25;
totalHours += allocation.hours;
}
}
// Apply 1.10 multiplier to total
const baseline = Math.round(totalCost * 1.10);
const stretch = Math.round(baseline * 1.2);
const avgRate = totalHours > 0 ? Math.round(totalCost / totalHours) : 0;
const calcNote = `${totalHours} hours at ~$${avgRate}/hr blended rate with markup`;
return { baseline, stretch, calcNote };
}
return {
suggestOffers
};
}