commit f4e4412bf2cc4aeb3eae59c4f6d877ef1ee5315f Author: michael Date: Sat Feb 21 09:49:16 2026 -0600 Initial commit: Build-A-Squad staffing calculator React 19 + TypeScript client-side app for creative agency staffing projections, powered by Google Gemini for AI scope analysis. Includes asset catalog, staffing routes, three-tab workflow (scoping, configurator, squad projection), scenario management, and CSV/PDF export. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfff9a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment variables +.env +.env.local +.env.*.local + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# OS files +Thumbs.db + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# TypeScript cache +*.tsbuildinfo diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..fee53ab --- /dev/null +++ b/App.tsx @@ -0,0 +1,798 @@ + +import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { + Search, + Users, + Trash2, + Plus, + TrendingUp, + Download, + Info, + LayoutGrid, + Calculator, + CheckCircle2, + Filter, + Copy, + Check, + Settings, + ChevronRight, + RefreshCw, + RotateCcw, + FileSpreadsheet, + Save, + Layers, + X, + ArrowRightLeft, + AlertCircle, + Sparkles, + Image as ImageIcon, + Upload +} from 'lucide-react'; +import { GoogleGenAI, Type } from "@google/genai"; +import { ASSETS_DATA, STAFFING_ROUTES, ROLE_DISCIPLINE_MAP, DISCIPLINES, ROLES, CATEGORIES } from './mockData'; +import { Asset, SelectedAsset, RoleProjection, ManualStaff, Scenario } from './types'; + +type TabType = 'scoping' | 'configurator' | 'squad'; + +interface ScopingResult { + scopeItem: string; + volume: number; + catalogId: string; + rationale: string; + asset?: Asset; +} + +export default function App() { + const [activeTab, setActiveTab] = useState('scoping'); + const [scopingInput, setScopingInput] = useState(''); + const [suggestedAssets, setSuggestedAssets] = useState([]); + const [addedRowIndices, setAddedRowIndices] = useState>(new Set()); + const [selectedAssets, setSelectedAssets] = useState([]); + const [draftVolumes, setDraftVolumes] = useState>({}); + const [billableHoursTarget, setBillableHoursTarget] = useState(1600); + const [manualStaff, setManualStaff] = useState(Array.from({ length: 4 }, () => ({ discipline: '', roleTitle: '', quantity: 0 }))); + const [overrides, setOverrides] = useState>({}); + const [auditText, setAuditText] = useState(""); + const [isAuditing, setIsAuditing] = useState(false); + const [isAnalyzingImage, setIsAnalyzingImage] = useState(false); + const [isAnalysingScope, setIsAnalysingScope] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + + const fileInputRef = useRef(null); + + // Scenario Management + const [scenarios, setScenarios] = useState([]); + const [newScenarioName, setNewScenarioName] = useState(''); + const [comparisonScenario, setComparisonScenario] = useState(null); + + // Filters + const [filterCategories, setFilterCategories] = useState(['All']); + const [filterType, setFilterType] = useState<'All' | 'Master' | 'Adapt'>('All'); + const [isPencilProOnly, setIsPencilProOnly] = useState(false); + + const handleResetAll = useCallback(() => { + if (window.confirm("Are you sure you want to reset the application? All scoped assets, scenarios, and manual staff inputs will be cleared.")) { + setScopingInput(''); + setSuggestedAssets([]); + setAddedRowIndices(new Set()); + setSelectedAssets([]); + setDraftVolumes({}); + setBillableHoursTarget(1600); + setManualStaff(Array.from({ length: 4 }, () => ({ discipline: '', roleTitle: '', quantity: 0 }))); + setOverrides({}); + setAuditText(""); + setScenarios([]); + setComparisonScenario(null); + setFilterCategories(['All']); + setFilterType('All'); + setIsPencilProOnly(false); + setActiveTab('scoping'); + } + }, []); + + const toggleCategory = (cat: string) => { + if (cat === 'All') { + setFilterCategories(['All']); + return; + } + const newList = filterCategories.filter(item => item !== 'All'); + if (newList.includes(cat)) { + const filtered = newList.filter(item => item !== cat); + setFilterCategories(filtered.length === 0 ? ['All'] : filtered); + } else { + setFilterCategories([...newList, cat]); + } + }; + + const handleAnalyseScope = async () => { + if (!scopingInput.trim()) { + setSuggestedAssets([]); + setAddedRowIndices(new Set()); + return; + } + + setIsAnalysingScope(true); + try { + const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); + + const catalogSummary = ASSETS_DATA.map(a => + `- Catalog ID: ${a.catalogId}, Name: ${a.assetName}, Category: ${a.category}, Complexity: ${a.complexityLevel}, Details: ${a.complexityDescription}` + ).join('\n'); + + const prompt = ` + Act as a Creative Scoping Expert. Analyze the following project brief/text and identify discrete creative assets needed. + Map each identified item to the CLOSEST matching asset in our catalog provided below. + + PROJECT BRIEF: + "${scopingInput}" + + CATALOG DATA: + ${catalogSummary} + + INSTRUCTIONS: + 1. Identify the "Scope Item" text from the brief. + 2. Extract or estimate the Volume (Vol.) for that item. + 3. Match it to a "Catalog ID" from the list above. + 4. Write a brief "Rationale" explaining why that specific Catalog ID and Complexity Level matches the brief (e.g., "Complexity 3 includes GenAI image generation where no imagery is provided"). + + Return the response as a valid JSON array of objects. + `; + + const response = await ai.models.generateContent({ + model: 'gemini-3-flash-preview', + contents: prompt, + config: { + responseMimeType: "application/json", + responseSchema: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + scopeItem: { type: Type.STRING }, + volume: { type: Type.NUMBER }, + catalogId: { type: Type.STRING }, + rationale: { type: Type.STRING } + }, + required: ["scopeItem", "volume", "catalogId", "rationale"] + } + } + } + }); + + const results = JSON.parse(response.text || "[]") as ScopingResult[]; + + const joinedResults = results.map(res => ({ + ...res, + asset: ASSETS_DATA.find(a => a.catalogId === res.catalogId) + })); + + setSuggestedAssets(joinedResults); + setAddedRowIndices(new Set()); + } catch (error) { + console.error("Scope analysis failed:", error); + } finally { + setIsAnalysingScope(false); + } + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsAnalyzingImage(true); + try { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = async () => { + const base64Data = (reader.result as string).split(',')[1]; + const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); + + const prompt = `Extract creative scope items and their quantities from this screenshot. + Identify standard marketing assets like "Social Posts", "Banners", "Films", etc. + For each, output the text found. + Format as a simple comma-separated list of items found.`; + + const response = await ai.models.generateContent({ + model: 'gemini-3-flash-preview', + contents: { + parts: [ + { inlineData: { data: base64Data, mimeType: file.type } }, + { text: prompt } + ] + } + }); + + const extractedText = response.text || ""; + setScopingInput(extractedText); + }; + } catch (error) { + console.error("OCR Analysis failed:", error); + } finally { + setIsAnalyzingImage(false); + } + }; + + const addAssetFromScoping = (result: ScopingResult, index: number) => { + if (!result.asset) return; + + const newIndices = new Set(addedRowIndices); + if (newIndices.has(index)) { + newIndices.delete(index); + removeAsset(result.asset.uniques); + } else { + newIndices.add(index); + const existing = selectedAssets.find(a => a.uniques === result.asset?.uniques); + if (existing) { + setSelectedAssets(selectedAssets.map(a => + a.uniques === result.asset?.uniques ? { ...a, volume: a.volume + result.volume } : a + )); + } else { + setSelectedAssets([...selectedAssets, { ...result.asset, volume: result.volume }]); + } + } + setAddedRowIndices(newIndices); + }; + + const addAsset = (asset: Asset, vol: number = 1) => { + if (selectedAssets.find(a => a.uniques === asset.uniques)) return; + setSelectedAssets([...selectedAssets, { ...asset, volume: Math.max(1, vol) }]); + }; + + const removeAsset = (uniques: string) => { + setSelectedAssets(selectedAssets.filter(a => a.uniques !== uniques)); + }; + + const updateVolume = (uniques: string, volume: number) => { + setSelectedAssets(selectedAssets.map(a => a.uniques === uniques ? { ...a, volume: Math.max(0, volume) } : a)); + }; + + const updateManualStaff = (index: number, field: keyof ManualStaff, value: any) => { + const newStaff = [...manualStaff]; + newStaff[index] = { ...newStaff[index], [field]: value }; + setManualStaff(newStaff); + }; + + const updateOverride = (roleKey: string, value: string) => { + const numValue = parseFloat(value); + setOverrides(prev => ({ + ...prev, + [roleKey]: isNaN(numValue) ? 0 : numValue + })); + }; + + const resetOverrides = () => { + setOverrides({}); + }; + + const calculatedSquad = useMemo(() => { + const roleHoursMap: Record = {}; + selectedAssets.forEach(asset => { + const hoursSet = STAFFING_ROUTES[asset.uniques]; + if (hoursSet) { + Object.entries(hoursSet).forEach(([role, hours]) => { + roleHoursMap[role] = (roleHoursMap[role] || 0) + (hours * asset.volume); + }); + } + }); + + return Object.entries(roleHoursMap) + .filter(([_, total]) => total > 0) + .map(([role, totalHours]): RoleProjection => { + const roleKey = `${ROLE_DISCIPLINE_MAP[role]}-${role}`; + return { + roleTitle: role, + discipline: ROLE_DISCIPLINE_MAP[role] || 'Other', + totalHours, + calculatedFte: totalHours / billableHoursTarget, + manualOverride: overrides[roleKey] + }; + }).sort((a, b) => a.discipline.localeCompare(b.discipline)); + }, [selectedAssets, billableHoursTarget, overrides]); + + const disciplineTotals = useMemo(() => { + const totals: Record = {}; + calculatedSquad.forEach(role => { + if (!totals[role.discipline]) totals[role.discipline] = { fte: 0, hours: 0 }; + const effectiveFte = role.manualOverride !== undefined ? role.manualOverride : role.calculatedFte; + totals[role.discipline].fte += effectiveFte; + totals[role.discipline].hours += role.totalHours; + }); + manualStaff.forEach(staff => { + if (staff.discipline && staff.quantity > 0) { + if (!totals[staff.discipline]) totals[staff.discipline] = { fte: 0, hours: 0 }; + totals[staff.discipline].fte += staff.quantity; + } + }); + return totals; + }, [calculatedSquad, manualStaff]); + + const totalSquadFte = useMemo(() => Object.values(disciplineTotals).reduce((sum, val) => sum + val.fte, 0), [disciplineTotals]); + const totalHours = useMemo(() => Object.values(disciplineTotals).reduce((sum, val) => sum + val.hours, 0), [disciplineTotals]); + + const runOperationalAudit = async () => { + if (selectedAssets.length === 0) return; + setIsAuditing(true); + try { + const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); + const prompt = ` + Act as a Strategic Partner for OLIVER APAC. Provide an Operational Audit for a team model presentation. + + Current Scope Data: + - Assets: ${selectedAssets.map(a => `${a.assetName} (${a.volume} units)`).join(', ')} + - Total FTE: ${totalSquadFte.toFixed(2)} + - Total Production Hours: ${totalHours.toLocaleString()} + - Discipline Breakdown: ${Object.entries(disciplineTotals).map(([d, data]) => `${d}: ${data.fte.toFixed(2)} FTE`).join(', ')} + + Structure your response with: + 1. **High-Impact Client Insights**: 2-3 strategic observations. + 2. **Critical Questions for Success**: 2-3 provocative questions. + + IMPORTANT: + - Do NOT include headers like "To:", "From:", or "Subject:". + - Start directly with a brief summary statement. + - Tone: Professional, senior, strategic, everyday English. + - Formatting: Use Markdown. + `; + const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt }); + setAuditText(response.text || "Audit unavailable."); + } catch (error) { + setAuditText("Error generating AI audit."); + } finally { + setIsAuditing(false); + } + }; + + const copyAuditToClipboard = () => { + if (!auditText) return; + navigator.clipboard.writeText(auditText).then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }); + }; + + useEffect(() => { + if (activeTab === 'squad' && !auditText && selectedAssets.length > 0) { + runOperationalAudit(); + } + }, [activeTab, selectedAssets.length, auditText, runOperationalAudit]); + + const downloadCSV = useCallback((data: any[], fileName: string) => { + if (data.length === 0) return; + const headers = Object.keys(data[0]).join(','); + const rows = data.map(obj => Object.values(obj).map(v => `"${v}"`).join(',')); + const csvContent = [headers, ...rows].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `${fileName}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, []); + + const exportCurrentTabCsv = () => { + if (activeTab === 'scoping') { + const exportData = suggestedAssets.map(s => ({ + Scope_Item: s.scopeItem, + Vol: s.volume, + Closest_Match: s.asset?.assetName || 'None', + Catalog_ID: s.catalogId, + Complexity: s.asset?.complexityLevel || 'None', + Rationale: s.rationale + })); + downloadCSV(exportData, 'matched_assets'); + } else if (activeTab === 'configurator') { + const exportData = sortedCatalogAssets.map(a => { + const selected = selectedAssets.find(sa => sa.uniques === a.uniques); + return { + ID: a.catalogId, + Name: a.assetName, + Category: a.category, + Complexity: a.complexityLevel, + Complexity_Description: a.complexityDescription, + Description: a.description, + Is_Master: a.isMaster ? 'Yes' : 'No', + Selected: selected ? 'Yes' : 'No', + Volume: selected ? selected.volume : 0 + }; + }); + downloadCSV(exportData, 'catalog_assets_full'); + } else { + const exportData = calculatedSquad.map(r => ({ + Discipline: r.discipline, Role: r.roleTitle, Calculated_Hours: r.totalHours.toFixed(1), Calculated_FTE: r.calculatedFte.toFixed(2), Manual_Override: r.manualOverride ?? '' + })); + downloadCSV(exportData, 'squad_projection'); + } + }; + + const exportScenarioCsv = (s: Scenario) => { + const assetsData = s.selectedAssets.map(a => ({ Type: 'Asset', Name: a.assetName, Volume: a.volume, Description: a.complexityDescription, Hours: '', FTE: '' })); + const summaryData = [{ Type: 'SUMMARY', Name: s.name, Volume: '', Description: 'Total Projection', Hours: s.totalHours, FTE: s.totalFte }]; + downloadCSV([...assetsData, ...summaryData], `Scenario_${s.name.replace(/\s+/g, '_')}`); + }; + + const saveScenario = () => { + if (!newScenarioName.trim()) return; + const scenario: Scenario = { id: crypto.randomUUID(), name: newScenarioName, selectedAssets: [...selectedAssets], overrides: { ...overrides }, manualStaff: [...manualStaff], totalFte: totalSquadFte, totalHours: totalHours, timestamp: Date.now() }; + setScenarios([...scenarios, scenario]); + setNewScenarioName(''); + }; + + const deleteScenario = (id: string) => { + setScenarios(scenarios.filter(s => s.id !== id)); + if (comparisonScenario?.id === id) setComparisonScenario(null); + }; + + const compareWith = (scenario: Scenario | null) => setComparisonScenario(scenario); + + const sortedCatalogAssets = useMemo(() => { + const filtered = ASSETS_DATA.filter(asset => { + const catMatch = filterCategories.includes('All') || filterCategories.includes(asset.category); + const typeMatch = filterType === 'All' || (filterType === 'Master' ? asset.isMaster : !asset.isMaster); + const pencilMatch = !isPencilProOnly || (asset.assetName.toLowerCase().includes('pencil pro') || asset.subCategory.toLowerCase().includes('pencil pro')); + return catMatch && typeMatch && pencilMatch; + }); + + return [...filtered].sort((a, b) => { + const aSelected = selectedAssets.some(sa => sa.uniques === a.uniques); + const bSelected = selectedAssets.some(sa => sa.uniques === b.uniques); + if (aSelected && !bSelected) return -1; + if (!aSelected && bSelected) return 1; + return 0; + }); + }, [selectedAssets, filterCategories, filterType, isPencilProOnly]); + + return ( +
+
+
+

+ Build-A-Squad +

+

Team Size Calculator

+
+
+ +
+ +
+

Annual Target

+
+ setBillableHoursTarget(Number(e.target.value))} + className="w-16 font-black text-[10px] border-none !p-0 focus:shadow-none bg-transparent text-black" + /> + hrs +
+
+
+ +
+
+ + + +
+
+ +
+ + {activeTab === 'scoping' && ( +
+
+
+
+
+

Brief Analyzer

+
+ + +
+
+

Paste brief text or upload a screenshot for AI analysis.

+