import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching"; import { offerTemplates, matchTemplateToInput, getTemplateHours } from '~/data/offerTemplates'; interface SuggestOffersInput { members: Member[]; selectedSkillsByMember: Record; 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; 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(); 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(); 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(); 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 }; }