256 lines
No EOL
8.5 KiB
TypeScript
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
|
|
};
|
|
} |