798 lines
48 KiB
TypeScript
798 lines
48 KiB
TypeScript
|
|
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<TabType>('scoping');
|
|
const [scopingInput, setScopingInput] = useState('');
|
|
const [suggestedAssets, setSuggestedAssets] = useState<ScopingResult[]>([]);
|
|
const [addedRowIndices, setAddedRowIndices] = useState<Set<number>>(new Set());
|
|
const [selectedAssets, setSelectedAssets] = useState<SelectedAsset[]>([]);
|
|
const [draftVolumes, setDraftVolumes] = useState<Record<string, number>>({});
|
|
const [billableHoursTarget, setBillableHoursTarget] = useState(1600);
|
|
const [manualStaff, setManualStaff] = useState<ManualStaff[]>(Array.from({ length: 4 }, () => ({ discipline: '', roleTitle: '', quantity: 0 })));
|
|
const [overrides, setOverrides] = useState<Record<string, number>>({});
|
|
const [auditText, setAuditText] = useState<string>("");
|
|
const [isAuditing, setIsAuditing] = useState(false);
|
|
const [isAnalyzingImage, setIsAnalyzingImage] = useState(false);
|
|
const [isAnalysingScope, setIsAnalysingScope] = useState(false);
|
|
const [copySuccess, setCopySuccess] = useState(false);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Scenario Management
|
|
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
|
const [newScenarioName, setNewScenarioName] = useState('');
|
|
const [comparisonScenario, setComparisonScenario] = useState<Scenario | null>(null);
|
|
|
|
// Filters
|
|
const [filterCategories, setFilterCategories] = useState<string[]>(['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.1-pro-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<HTMLInputElement>) => {
|
|
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.1-pro-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<string, number> = {};
|
|
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<string, { fte: number, hours: number }> = {};
|
|
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.1-pro-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 (
|
|
<div className="min-h-screen p-4 md:p-8 max-w-[1600px] mx-auto space-y-6 bg-[#F3F3F3]">
|
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 print:hidden">
|
|
<div>
|
|
<h1 className="text-4xl md:text-6xl font-black uppercase tracking-tighter leading-none mb-1 text-black">
|
|
Build-A-<span className="bg-[#F5C518] px-2 border-4 border-black inline-block transform rotate-1 text-black">Squad</span>
|
|
</h1>
|
|
<p className="text-lg font-bold text-gray-900 uppercase tracking-wide">Team Size Calculator</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleResetAll}
|
|
className="neo-brutal-small bg-white text-black px-4 py-3 font-black flex items-center gap-2 neo-brutal-interactive uppercase text-xs border-4 border-black shadow-none cursor-pointer"
|
|
>
|
|
<RotateCcw size={18} className="text-red-500" /> <span className="text-black">Reset App</span>
|
|
</button>
|
|
<div className="neo-brutal-small bg-white p-1 px-3 flex items-center gap-3 border-black border-4">
|
|
<Settings size={18} className="text-gray-500" />
|
|
<div className="border-l-2 border-black pl-3">
|
|
<p className="text-[9px] font-black uppercase leading-tight text-gray-500">Annual Target</p>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="number"
|
|
value={billableHoursTarget}
|
|
onChange={(e) => setBillableHoursTarget(Number(e.target.value))}
|
|
className="w-16 font-black text-[10px] border-none !p-0 focus:shadow-none bg-transparent text-black"
|
|
/>
|
|
<span className="font-black text-[10px] uppercase text-black">hrs</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => window.print()} className="neo-brutal-small bg-[#F5C518] text-black px-4 py-3 font-black flex items-center gap-2 neo-brutal-interactive uppercase text-xs border-4 border-black shadow-none print:hidden">
|
|
<Download size={18} className="text-black" /> <span className="text-black">Export PDF</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className="flex gap-2 border-b-4 border-black pb-0 print:hidden overflow-x-auto whitespace-nowrap scrollbar-hide">
|
|
{[
|
|
{ id: 'scoping', label: '1. Scoping', icon: Search },
|
|
{ id: 'configurator', label: '2. Catalog', icon: LayoutGrid },
|
|
{ id: 'squad', label: '3. Projection', icon: Calculator }
|
|
].map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as TabType)}
|
|
className={`flex items-center gap-2 px-6 py-4 font-black uppercase tracking-tight text-base border-t-4 border-x-4 border-black transition-all ${
|
|
activeTab === tab.id ? 'bg-black text-[#F5C518] -translate-y-1' : 'bg-white text-black hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<tab.icon size={18} /> {tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<main>
|
|
<div className="flex justify-end mb-3 print:hidden">
|
|
<button onClick={exportCurrentTabCsv} className="flex items-center gap-2 bg-white border-4 border-black px-3 py-1.5 font-black uppercase text-[10px] neo-brutal-interactive text-black">
|
|
<FileSpreadsheet size={14} className="text-black" /> <span className="text-black">Export Tab CSV</span>
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'scoping' && (
|
|
<div className="space-y-6 print:hidden">
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
<div className="lg:col-span-4">
|
|
<section className="neo-brutal bg-white p-6 space-y-4 border-4 border-black h-full">
|
|
<div className="flex justify-between items-center">
|
|
<h2 className="text-2xl font-black uppercase text-black">Brief Analyzer</h2>
|
|
<div className="flex items-center gap-2">
|
|
<input type="file" accept="image/*" ref={fileInputRef} className="hidden" onChange={handleImageUpload} />
|
|
<button onClick={() => fileInputRef.current?.click()} disabled={isAnalyzingImage} className="flex items-center gap-2 bg-[#F5C518] border-4 border-black px-3 py-1.5 font-black uppercase text-[10px] neo-brutal-interactive text-black">
|
|
{isAnalyzingImage ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
|
|
<span>Upload Screenshot</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm font-bold text-gray-600 uppercase">Paste brief text or upload a screenshot for AI analysis.</p>
|
|
<textarea value={scopingInput} onChange={(e) => setScopingInput(e.target.value)} className="w-full h-[400px] p-4 font-bold text-lg border-4 border-black bg-white text-black" placeholder="e.g. 5x Digital Films, 2x Social Posts..." />
|
|
<button onClick={handleAnalyseScope} disabled={isAnalysingScope} className="w-full py-4 bg-black text-[#F5C518] font-black text-xl uppercase neo-brutal-interactive border-4 border-black flex items-center justify-center gap-2 disabled:opacity-50">
|
|
{isAnalysingScope ? <RefreshCw size={22} className="animate-spin" /> : <Search size={22} />}
|
|
<span>Analyse & Scope</span>
|
|
</button>
|
|
</section>
|
|
</div>
|
|
<div className="lg:col-span-8">
|
|
<section className="neo-brutal bg-white border-4 border-black h-full flex flex-col">
|
|
<div className="bg-black text-[#F5C518] p-4 flex justify-between items-center">
|
|
<h3 className="text-xl font-black uppercase tracking-widest">Matched Assets</h3>
|
|
{selectedAssets.length > 0 && (
|
|
<button onClick={() => setActiveTab('configurator')} className="bg-[#F5C518] text-black px-3 py-1.5 text-[10px] font-black uppercase border-4 border-black neo-brutal-interactive flex items-center gap-2">Refine Squad <ChevronRight size={14} /></button>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-x-auto">
|
|
<table className="w-full text-left border-collapse min-w-[900px]">
|
|
<thead className="bg-[#444444] text-white font-black uppercase text-sm tracking-widest">
|
|
<tr>
|
|
<th className="p-5 border-r border-gray-600">Scope Item</th>
|
|
<th className="p-5 border-r border-gray-600 text-center w-24">Vol.</th>
|
|
<th className="p-5 border-r border-gray-600">Closest Match Asset Name</th>
|
|
<th className="p-5 border-r border-gray-600">SWOP Catalog ID</th>
|
|
<th className="p-5 border-r border-gray-600">Complexity Level</th>
|
|
<th className="p-5 border-r border-gray-600">Rationale</th>
|
|
<th className="p-5 text-center">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="text-lg font-bold divide-y divide-gray-200">
|
|
{suggestedAssets.length > 0 ? suggestedAssets.map((result, idx) => {
|
|
const isAdded = addedRowIndices.has(idx);
|
|
return (
|
|
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
|
<td className="p-5 font-black text-black align-top">{result.scopeItem}</td>
|
|
<td className="p-5 text-center text-black align-top font-black">{result.volume}</td>
|
|
<td className="p-5 text-black align-top">{result.asset?.assetName || 'No Match'}</td>
|
|
<td className="p-5 text-black align-top font-mono text-sm">{result.catalogId}</td>
|
|
<td className="p-5 text-black align-top">{result.asset?.complexityLevel || 'N/A'}</td>
|
|
<td className="p-5 text-gray-800 leading-relaxed align-top text-base">{result.rationale}</td>
|
|
<td className="p-5 text-center align-top">
|
|
<button
|
|
onClick={() => addAssetFromScoping(result, idx)}
|
|
className={`p-4 border-4 border-black transition-transform neo-brutal-interactive ${isAdded ? 'bg-green-500 text-white' : 'bg-[#F5C518] text-black'}`}
|
|
title={isAdded ? "Added to Squad" : "Add to Squad"}
|
|
>
|
|
{isAdded ? <Check size={26} strokeWidth={4} /> : <Plus size={26} strokeWidth={4} />}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}) : (
|
|
<tr>
|
|
<td colSpan={7} className="p-20 text-center text-gray-300 uppercase font-black text-3xl">
|
|
Run Scoping Analysis to See Matches
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'configurator' && (
|
|
<div className="space-y-6 print:hidden">
|
|
<section className="neo-brutal bg-white p-4 border-4 border-black flex flex-wrap gap-6 items-end">
|
|
<div className="flex-1 min-w-[250px]"><label className="block text-[10px] font-black uppercase mb-2 text-gray-900 flex items-center gap-1.5"><Filter size={12}/> Categories</label><div className="flex flex-wrap gap-1.5">{['All', ...CATEGORIES].map(cat => (<button key={cat} onClick={() => toggleCategory(cat)} className={`px-3 py-1 text-[10px] font-black uppercase border-2 border-black transition-all ${filterCategories.includes(cat) ? 'bg-black text-[#F5C518]' : 'bg-white text-black hover:bg-gray-50'}`}>{cat}</button>))}</div></div>
|
|
<div className="w-48"><label className="block text-[10px] font-black uppercase mb-2 text-gray-900 flex items-center gap-1.5"><Layers size={12}/> Asset Type</label><div className="flex gap-1.5">{['All', 'Master', 'Adapt'].map(type => (<button key={type} onClick={() => setFilterType(type as any)} className={`flex-1 p-1.5 text-[10px] font-black uppercase border-2 border-black transition-all ${filterType === type ? 'bg-black text-[#F5C518]' : 'bg-white text-black'}`}>{type}</button>))}</div></div>
|
|
<div className="w-32"><label className="block text-[10px] font-black uppercase mb-2 text-gray-900 flex items-center gap-1.5"><Sparkles size={12}/> AI Filter</label><button onClick={() => setIsPencilProOnly(!isPencilProOnly)} className={`w-full p-1.5 text-[10px] font-black uppercase border-2 border-black transition-all ${isPencilProOnly ? 'bg-[#F5C518] text-black' : 'bg-white text-black hover:bg-gray-50'}`}>Pencil Pro</button></div>
|
|
</section>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{sortedCatalogAssets.map((asset, idx) => {
|
|
const selected = selectedAssets.find(a => a.uniques === asset.uniques);
|
|
const displayVolume = selected ? selected.volume : (draftVolumes[asset.uniques] || 0);
|
|
|
|
return (
|
|
<div key={`${asset.catalogId}-${idx}`} className={`neo-brutal p-4 border-4 border-black flex flex-col justify-between min-h-[240px] transition-all group relative overflow-hidden ${selected ? 'bg-[#F5C518]/10' : 'bg-white hover:-translate-y-0.5'}`}>
|
|
<div className="flex flex-col flex-1 h-full relative z-0">
|
|
<div className="flex justify-between items-start mb-3 text-black">
|
|
<span className="text-[8px] font-black uppercase bg-black text-white px-1.5 py-0.5">{asset.catalogId}</span>
|
|
<span className={`text-[8px] font-black uppercase border-2 border-black px-1.5 py-0.5 ${asset.isMaster ? 'bg-blue-100' : 'bg-purple-100'} text-black`}>{asset.isMaster ? 'Master' : 'Adapt'}</span>
|
|
</div>
|
|
<div className="relative flex-1">
|
|
<h4 className="font-black text-lg uppercase leading-none mb-1 text-black truncate" title={asset.assetName}>{asset.assetName}</h4>
|
|
<p className="text-[9px] font-black text-gray-500 uppercase mb-3 truncate">{asset.category} — {asset.complexityLevel}</p>
|
|
<p className="text-[10px] text-gray-600 line-clamp-2 leading-relaxed transition-opacity group-hover:opacity-0">
|
|
{asset.complexityDescription}
|
|
</p>
|
|
<div className="absolute inset-x-0 top-0 bottom-0 opacity-0 group-hover:opacity-100 bg-white pointer-events-none transition-opacity duration-200 overflow-y-auto">
|
|
<p className="text-[10px] font-black uppercase text-black mb-1 underline">Project Context:</p>
|
|
<p className="text-[10px] text-black leading-tight mb-2">{asset.description}</p>
|
|
<p className="text-[10px] font-black uppercase text-black mb-1 underline">Complexity Details:</p>
|
|
<p className="text-[10px] text-black leading-tight">{asset.complexityDescription}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 items-end pt-3 border-t-2 border-black border-dashed mt-2 relative z-10">
|
|
<div className="flex-1">
|
|
<label className="text-[8px] font-black uppercase text-gray-500 mb-1 block">Qty</label>
|
|
<input
|
|
type="number"
|
|
value={displayVolume || ''}
|
|
placeholder="0"
|
|
onChange={(e) => {
|
|
const val = parseInt(e.target.value) || 0;
|
|
if (selected) {
|
|
updateVolume(asset.uniques, val);
|
|
} else {
|
|
setDraftVolumes(prev => ({ ...prev, [asset.uniques]: val }));
|
|
}
|
|
}}
|
|
className={`w-full p-2 text-center font-black text-xl border-4 border-black transition-colors ${selected ? 'bg-[#F5C518] text-black' : 'bg-gray-50 text-black'}`}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
if (selected) {
|
|
removeAsset(asset.uniques);
|
|
} else {
|
|
const vol = draftVolumes[asset.uniques] || 1;
|
|
addAsset(asset, vol);
|
|
}
|
|
}}
|
|
className={`border-4 border-black p-2 neo-brutal-interactive text-black ${selected ? 'bg-red-500' : 'bg-[#F5C518]'} w-10 h-10 flex items-center justify-center`}
|
|
>
|
|
{selected ? <Trash2 size={18} strokeWidth={3} /> : <Plus size={18} strokeWidth={3} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex justify-center pt-6 pb-10"><button onClick={() => setActiveTab('squad')} className="bg-black text-[#F5C518] px-12 py-6 font-black text-2xl uppercase border-4 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,0.2)] hover:shadow-[10px_10px_0px_0px_rgba(0,0,0,1)] hover:translate-x-0.5 hover:-translate-y-0.5 transition-all flex items-center gap-4">View Full Team Projection <ChevronRight size={32} strokeWidth={4} /></button></div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'squad' && (
|
|
<div className="space-y-6 pb-10">
|
|
<section className="neo-brutal bg-white p-6 border-4 border-black">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<h3 className="font-black uppercase text-xl bg-black text-white px-3 py-1 inline-block tracking-widest">Active Scope</h3>
|
|
<p className="text-[10px] font-bold text-gray-500 uppercase">Edit volumes directly below.</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{selectedAssets.length > 0 ? selectedAssets.map((asset, i) => (
|
|
<div key={i} className="flex justify-between items-center p-3 border-2 border-black bg-gray-50 hover:border-[#F5C518] transition-colors">
|
|
<div className="flex flex-col flex-1 mr-2 min-w-0">
|
|
<span className="font-black uppercase text-[10px] text-black truncate">{asset.assetName}</span>
|
|
<span className="text-[8px] font-bold text-gray-400">{asset.catalogId}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
value={asset.volume}
|
|
onChange={(e) => updateVolume(asset.uniques, parseInt(e.target.value) || 0)}
|
|
className="w-20 p-1 text-center font-black text-xs border-2 border-black bg-white text-black"
|
|
/>
|
|
<button onClick={() => removeAsset(asset.uniques)} className="text-gray-400 hover:text-red-500"><X size={14}/></button>
|
|
</div>
|
|
</div>
|
|
)) : <div className="col-span-full p-8 text-center text-gray-300 uppercase font-black border-2 border-black border-dashed">No assets scoped.</div>}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="neo-brutal bg-white p-4 border-4 border-black flex flex-col md:flex-row justify-between items-center gap-4 print:hidden">
|
|
<div className="flex items-center gap-3"><Layers size={24} className="text-black" /><div><h3 className="text-xl font-black uppercase text-black leading-none">Scenarios</h3><p className="text-[8px] font-bold uppercase text-gray-500 tracking-widest">Compare team versions</p></div></div>
|
|
<div className="flex gap-3 w-full md:w-auto">
|
|
<input type="text" placeholder="New Name..." value={newScenarioName} onChange={(e) => setNewScenarioName(e.target.value)} className="flex-1 md:w-48 p-2 border-4 border-black bg-[#F3F3F3] font-black uppercase text-[10px]" />
|
|
<button onClick={saveScenario} disabled={!newScenarioName.trim()} className="bg-black text-[#F5C518] px-4 py-2 font-black uppercase text-[10px] border-4 border-black neo-brutal-interactive flex items-center gap-1.5 disabled:opacity-50"><Save size={14} /> Save</button>
|
|
</div>
|
|
</div>
|
|
|
|
{scenarios.length > 0 && (
|
|
<div className="flex gap-3 overflow-x-auto pb-2 print:hidden">
|
|
{scenarios.map(s => (
|
|
<div key={s.id} className={`neo-brutal-small min-w-[240px] p-3 flex flex-col justify-between transition-all ${comparisonScenario?.id === s.id ? 'bg-[#F5C518] border-black' : 'bg-white border-gray-400'}`}>
|
|
<div>
|
|
<div className="flex justify-between items-start mb-1.5"><span className="text-[8px] font-black uppercase bg-black text-white px-1.5 py-0.5">Scenario</span><div className="flex gap-2"><button onClick={() => exportScenarioCsv(s)} className="text-black hover:text-blue-600 transition-colors" title="Export CSV"><FileSpreadsheet size={12} /></button><button onClick={() => deleteScenario(s.id)} className="text-gray-400 hover:text-red-500 transition-colors"><Trash2 size={12} /></button></div></div>
|
|
<h4 className="font-black uppercase text-base leading-tight mb-1.5 truncate">{s.name}</h4>
|
|
<div className="flex justify-between text-[10px] font-bold uppercase text-gray-600 mb-3"><span>{s.totalFte.toFixed(2)} FTE</span><span>•</span><span>{s.totalHours.toLocaleString()}h</span></div>
|
|
</div>
|
|
<button onClick={() => compareWith(comparisonScenario?.id === s.id ? null : s)} className={`w-full py-1.5 font-black uppercase text-[9px] border-2 border-black transition-all ${comparisonScenario?.id === s.id ? 'bg-black text-white' : 'bg-white text-black hover:bg-gray-100'}`}>{comparisonScenario?.id === s.id ? 'Active' : 'Compare'}</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
<div className="lg:col-span-8 space-y-6 print:col-span-12">
|
|
<section className="neo-brutal bg-white border-4 border-black overflow-hidden">
|
|
<div className="bg-black text-white p-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div className="flex items-center gap-4"><div className="p-2 bg-[#F5C518] text-black border-2 border-white"><Users size={32} strokeWidth={3} /></div><div><h2 className="text-3xl font-black uppercase tracking-tighter leading-none mb-1">Squad Breakdown</h2><p className="text-[#F5C518] font-bold uppercase text-[9px] tracking-widest">Resource Matrix</p></div></div>
|
|
<div className="flex flex-col items-end bg-[#F5C518] text-black p-3 border-4 border-white shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)]"><span className="text-[8px] font-black uppercase tracking-widest leading-none mb-1 opacity-70">Total Squad FTE</span><div className="flex items-baseline gap-2"><span className="text-5xl font-black leading-none tracking-tighter">{totalSquadFte.toFixed(2)}</span>{comparisonScenario && (<span className={`text-sm font-black mb-1 ${totalSquadFte > comparisonScenario.totalFte ? 'text-red-700' : 'text-green-700'}`}>{totalSquadFte > comparisonScenario.totalFte ? '↑' : '↓'} {(totalSquadFte - comparisonScenario.totalFte).toFixed(2)}</span>)}</div></div>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead className="bg-black text-[#F5C518] font-black uppercase text-[10px] tracking-widest">
|
|
<tr><th className="p-3 border-r-2 border-gray-800">Resource Role</th><th className="p-3 border-r-2 border-gray-800 text-center">Hours</th><th className="p-3 border-r-2 border-gray-800 text-center">FTE</th><th className="p-3 text-center bg-[#F5C518] text-black">Override</th></tr>
|
|
</thead>
|
|
<tbody className="font-bold text-black text-sm text-black">
|
|
{calculatedSquad.length > 0 ? calculatedSquad.map(role => {
|
|
const roleKey = `${role.discipline}-${role.roleTitle}`;
|
|
return (
|
|
<tr key={roleKey} className="border-b-2 border-black hover:bg-[#F5C518]/5 transition-colors">
|
|
<td className="p-2.5 border-r-2 border-black"><div className="text-[8px] font-black uppercase text-gray-500 mb-0.5 leading-none">{role.discipline}</div><div className="uppercase tracking-tighter leading-tight text-xs">{role.roleTitle}</div></td>
|
|
<td className="p-2.5 border-r-2 border-black text-center font-mono text-gray-400 text-xs">{role.totalHours.toLocaleString()}h</td>
|
|
<td className="p-2.5 border-r-2 border-black text-center font-black text-lg">{role.calculatedFte.toFixed(2)}</td>
|
|
<td className="p-2.5 bg-[#F5C518]/5 text-center"><div className="flex items-center gap-1.5 justify-center"><input type="number" step="0.05" value={role.manualOverride ?? ''} onChange={(e) => updateOverride(roleKey, e.target.value)} className="w-16 p-1 text-center font-black text-base border-2 border-black bg-white text-black" />{role.manualOverride !== undefined && (<button onClick={() => { const next = { ...overrides }; delete next[roleKey]; setOverrides(next); }} className="p-0.5 hover:text-red-500 text-gray-400" title="Clear"><RotateCcw size={12} /></button>)}</div></td>
|
|
</tr>
|
|
);
|
|
}) : (<tr><td colSpan={4} className="p-10 text-center text-gray-300 uppercase font-black">Waiting for scope</td></tr>)}
|
|
<tr className="bg-black text-[#F5C518] border-y-2 border-black"><td colSpan={4} className="p-2 text-[9px] font-black uppercase text-center tracking-[0.4em]">Bespoke Additions</td></tr>
|
|
{manualStaff.map((staff, idx) => (
|
|
<tr key={idx} className="border-b border-black bg-white">
|
|
<td className="p-2 border-r-2 border-black"><div className="flex gap-2"><select className="flex-1 p-1 text-[9px] border-2 border-black bg-gray-50 font-black uppercase text-black" value={staff.discipline} onChange={(e) => updateManualStaff(idx, 'discipline', e.target.value)}><option value="">Discipline</option>{DISCIPLINES.map(d => <option key={d} value={d}>{d}</option>)}</select><select className="flex-1 p-1 text-[9px] border-2 border-black bg-gray-50 font-black uppercase text-black" value={staff.roleTitle} onChange={(e) => updateManualStaff(idx, 'roleTitle', e.target.value)}><option value="">Role</option>{ROLES.map(r => <option key={r} value={r}>{r}</option>)}</select></div></td>
|
|
<td className="p-2 border-r-2 border-black text-center text-gray-300 font-mono text-[10px] uppercase">Manual</td><td className="p-2 border-r-2 border-black text-center text-gray-300">-</td><td className="p-2 bg-[#F5C518]/5 text-center"><input type="number" step="0.1" value={staff.quantity || ''} placeholder="0.0" onChange={(e) => updateManualStaff(idx, 'quantity', parseFloat(e.target.value) || 0)} className="w-16 p-1 text-center font-black text-base border-2 border-black bg-white text-black" /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{calculatedSquad.length > 0 && (<div className="bg-gray-50 p-2 text-right border-t border-black print:hidden"><button onClick={resetOverrides} className="flex items-center gap-1.5 bg-black text-[#F5C518] px-3 py-1 font-black uppercase text-[9px] ml-auto neo-brutal-interactive"><RotateCcw size={12} /> Reset Overrides</button></div>)}
|
|
</section>
|
|
</div>
|
|
<div className="lg:col-span-4 space-y-6 print:hidden">
|
|
<section className="neo-brutal bg-white p-6 space-y-6 border-4 border-black">
|
|
<div className="flex items-center justify-between border-b-4 border-black pb-3">
|
|
<div className="flex items-center gap-2"><Sparkles className="text-[#F5C518]" size={24} /><h2 className="text-2xl font-black uppercase text-black tracking-tight">Operational Audit</h2></div>
|
|
<div className="flex gap-2">
|
|
<button onClick={copyAuditToClipboard} disabled={!auditText} title="Copy Audit" className="p-2 border-4 border-black bg-white text-black hover:bg-black hover:text-white transition-all neo-brutal-interactive disabled:opacity-50">
|
|
{copySuccess ? <Check size={18} className="text-green-500" /> : <Copy size={18} />}
|
|
</button>
|
|
<button onClick={runOperationalAudit} disabled={isAuditing || selectedAssets.length === 0} className="p-2 border-4 border-black bg-[#F5C518] text-black hover:bg-black hover:text-[#F5C518] transition-all neo-brutal-interactive disabled:opacity-50"><RefreshCw size={18} className={isAuditing ? 'animate-spin' : ''} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className={`p-6 bg-white text-black border-4 border-black font-medium text-sm leading-relaxed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] relative min-h-[300px] overflow-y-auto max-h-[500px] custom-scrollbar ${isAuditing ? 'opacity-50' : ''}`}>
|
|
{isAuditing ? (<div className="flex flex-col items-center justify-center h-full space-y-4 py-10"><RefreshCw size={32} className="animate-spin text-black" /><p className="font-black uppercase text-xs text-center">Consulting...</p></div>) : auditText ? (
|
|
<div className="prose prose-sm max-w-none prose-p:mb-4 prose-headings:text-black prose-headings:font-black prose-headings:uppercase prose-headings:mt-6 prose-strong:font-black text-black">
|
|
{auditText.split('\n').map((line, i) => {
|
|
if (line.startsWith('#')) return <h4 key={i} className="text-black font-black uppercase mb-2 mt-4 text-base">{line.replace(/#+\s*/, '')}</h4>;
|
|
if (line.startsWith('* **')) return <p key={i} className="mb-2 pl-4 border-l-2 border-[#F5C518] text-black">{line.replace(/^\*\s*/, '')}</p>;
|
|
if (line.startsWith('**')) return <p key={i} className="font-black mb-1 text-black">{line}</p>;
|
|
return <p key={i} className="mb-2 text-black">{line}</p>;
|
|
})}
|
|
</div>
|
|
) : (<div className="flex flex-col items-center justify-center h-full text-center space-y-4 opacity-50"><Calculator size={48} /><p className="font-black uppercase text-xs text-black">Define scope to trigger AI Audit</p></div>)}
|
|
</div>
|
|
<div className="space-y-3 pt-2 border-t-2 border-black border-dashed">
|
|
<h3 className="font-black uppercase text-[10px] bg-red-600 text-white px-3 py-1 inline-block tracking-widest">Studio Caveats</h3>
|
|
<div className="max-h-48 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
|
{selectedAssets.some(a => a.studioCaveats) ? selectedAssets.map((asset, i) => asset.studioCaveats ? (<div key={i} className="text-[9px] font-bold uppercase text-black border-l-4 border-red-600 pl-3 py-2 bg-red-50/50 flex flex-col gap-0.5"><span className="text-[7px] text-gray-400 font-black">{asset.catalogId} — {asset.assetName}</span><span className="leading-tight">{asset.studioCaveats}</span></div>) : null) : <p className="text-[10px] font-bold text-gray-400 uppercase">No specific caveats.</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section className="neo-brutal bg-black text-white p-8 border-4 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
<div className="space-y-6 text-center">
|
|
<TrendingUp className="mx-auto text-[#F5C518]" size={48} strokeWidth={3} />
|
|
<div className="space-y-1"><p className="text-[9px] font-black uppercase tracking-[0.2em] text-gray-500">Resource Load</p><p className="text-5xl font-black text-[#F5C518] tracking-tighter leading-none">{totalHours.toLocaleString()}</p><p className="text-[10px] font-black uppercase tracking-widest text-gray-400">Projected Hours</p></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|