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
|
|
@ -58,7 +58,7 @@ export const useFixtures = () => {
|
|||
category: 'Services',
|
||||
subcategory: 'Development',
|
||||
targetPct: 65,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 13000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 30,
|
||||
terms: 'Net 30',
|
||||
|
|
@ -73,7 +73,7 @@ export const useFixtures = () => {
|
|||
category: 'Product',
|
||||
subcategory: 'Digital Tools',
|
||||
targetPct: 20,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 4000,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 14,
|
||||
terms: 'Platform payout',
|
||||
|
|
@ -88,7 +88,7 @@ export const useFixtures = () => {
|
|||
category: 'Grant',
|
||||
subcategory: 'Government',
|
||||
targetPct: 10,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 2000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 45,
|
||||
terms: 'Quarterly disbursement',
|
||||
|
|
@ -103,7 +103,7 @@ export const useFixtures = () => {
|
|||
category: 'Donation',
|
||||
subcategory: 'Individual',
|
||||
targetPct: 3,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 600,
|
||||
certainty: 'Aspirational',
|
||||
payoutDelayDays: 3,
|
||||
terms: 'Immediate',
|
||||
|
|
@ -118,7 +118,7 @@ export const useFixtures = () => {
|
|||
category: 'Other',
|
||||
subcategory: 'Professional Services',
|
||||
targetPct: 2,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 400,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 21,
|
||||
terms: 'Net 21',
|
||||
|
|
|
|||
256
composables/useOfferSuggestor.ts
Normal file
256
composables/useOfferSuggestor.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue