- Brief Analysis now accepts pasted text OR uploaded file - Textarea for typing/pasting brief directly (no upload required) - Re-analyze button returns to input screen - Team Shape Excel sheets now use formulas: - FTE = Hours/1800 (formula) - Adjusted Hours = Original * (1-eff%) (formula) - Hours Saved = Original - Adjusted (formula) - Headcount = IF/CEILING formula - Base team shape also uses FTE + headcount formulas - All sheets are now formula-driven, Finance can edit hours and see recalculation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1009 lines
41 KiB
TypeScript
1009 lines
41 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
import api from '../api/client';
|
|
import { Project, ClientAsset, Match, RatecardSummary, MODEL_TYPE_LABELS, CONFIDENCE_COLORS } from '../types';
|
|
import './ProjectView.css';
|
|
|
|
type Tab = 'upload' | 'analysis' | 'matches' | 'ratecard' | 'team';
|
|
|
|
interface TeamRole {
|
|
role_id: number;
|
|
role_title: string;
|
|
discipline: string;
|
|
is_programme_role: boolean;
|
|
total_hours: number;
|
|
fte: number;
|
|
}
|
|
|
|
interface TeamShape {
|
|
project_id: number;
|
|
total_hours: number;
|
|
total_fte: number;
|
|
delivery_fte: number;
|
|
programme_fte: number;
|
|
roles: TeamRole[];
|
|
}
|
|
|
|
interface EffProfile {
|
|
id: number;
|
|
name: string;
|
|
is_default: boolean;
|
|
rates: Record<string, number>;
|
|
}
|
|
|
|
interface EffTool {
|
|
id: number;
|
|
tool_name: string;
|
|
tool_description: string;
|
|
rates: Record<string, number>;
|
|
}
|
|
|
|
const CONF_CLASS: Record<string, string> = {
|
|
exact: 'conf-exact',
|
|
close: 'conf-close',
|
|
multiple: 'conf-multiple',
|
|
none: 'conf-none',
|
|
};
|
|
|
|
export default function ProjectView() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [tab, setTab] = useState<Tab>('upload');
|
|
const [assets, setAssets] = useState<ClientAsset[]>([]);
|
|
const [matches, setMatches] = useState<Match[]>([]);
|
|
const [ratecard, setRatecard] = useState<RatecardSummary | null>(null);
|
|
const [teamShape, setTeamShape] = useState<TeamShape | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadStage, setUploadStage] = useState('');
|
|
const [refineInput, setRefineInput] = useState('');
|
|
const [refining, setRefining] = useState(false);
|
|
const [refineLog, setRefineLog] = useState<string[]>([]);
|
|
const [briefAnalysis, setBriefAnalysis] = useState<any>(null);
|
|
const [analyzing, setAnalyzing] = useState(false);
|
|
const [briefText, setBriefText] = useState('');
|
|
const [matching, setMatching] = useState(false);
|
|
const [building, setBuilding] = useState(false);
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
|
const [previewEfficiency, setPreviewEfficiency] = useState(0);
|
|
const [selectedEfficiencyLevels, setSelectedEfficiencyLevels] = useState<Set<number>>(new Set());
|
|
const [profiles, setProfiles] = useState<EffProfile[]>([]);
|
|
const [tools, setTools] = useState<EffTool[]>([]);
|
|
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(null);
|
|
const [selectedToolIds, setSelectedToolIds] = useState<Set<number>>(new Set());
|
|
|
|
const loadProject = useCallback(async () => {
|
|
try {
|
|
const [projRes, assetsRes] = await Promise.all([
|
|
api.get(`/projects/${id}`),
|
|
api.get(`/projects/${id}/client-assets`),
|
|
]);
|
|
setProject(projRes.data);
|
|
setAssets(assetsRes.data);
|
|
|
|
if (assetsRes.data.length > 0) {
|
|
const matchRes = await api.get(`/projects/${id}/matches`);
|
|
setMatches(matchRes.data);
|
|
}
|
|
|
|
// Load brief analysis if available
|
|
try {
|
|
const analysisRes = await api.get(`/projects/${id}/brief-analysis`);
|
|
if (analysisRes.data.status === 'analyzed') {
|
|
setBriefAnalysis(analysisRes.data.analysis);
|
|
}
|
|
} catch {}
|
|
|
|
if (['finalized', 'building'].includes(projRes.data.status)) {
|
|
try {
|
|
const [rcRes, tsRes] = await Promise.all([
|
|
api.get(`/projects/${id}/ratecard`),
|
|
api.get(`/projects/${id}/team-shape`),
|
|
]);
|
|
setRatecard(rcRes.data);
|
|
setTeamShape(tsRes.data);
|
|
} catch {}
|
|
}
|
|
|
|
if (projRes.data.status === 'finalized') setTab('ratecard');
|
|
else if (assetsRes.data.length > 0) setTab('matches');
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => { loadProject(); }, [loadProject]);
|
|
|
|
useEffect(() => {
|
|
async function loadEfficiency() {
|
|
try {
|
|
const [pRes, tRes] = await Promise.all([
|
|
api.get('/efficiency/profiles'),
|
|
api.get('/efficiency/tools'),
|
|
]);
|
|
setProfiles(pRes.data);
|
|
setTools(tRes.data);
|
|
const defaultProfile = pRes.data.find((p: EffProfile) => p.is_default);
|
|
if (defaultProfile) setSelectedProfileId(defaultProfile.id);
|
|
} catch {}
|
|
}
|
|
loadEfficiency();
|
|
}, []);
|
|
|
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploading(true);
|
|
setUploadStage(`Uploading ${file.name}...`);
|
|
|
|
try {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
await api.post(`/projects/${id}/upload`, form);
|
|
} catch (err: any) {
|
|
alert(`Upload failed: ${err.response?.data?.detail || err.message}`);
|
|
setUploading(false);
|
|
setUploadStage('');
|
|
return;
|
|
}
|
|
|
|
// Poll until background parsing completes (status leaves 'parsing')
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const res = await api.get(`/projects/${id}`);
|
|
if (res.data.parse_stage) {
|
|
setUploadStage(res.data.parse_stage);
|
|
}
|
|
if (res.data.status !== 'parsing') {
|
|
clearInterval(pollInterval);
|
|
setUploading(false);
|
|
setUploadStage('');
|
|
await loadProject();
|
|
setTab('matches');
|
|
}
|
|
} catch {}
|
|
}, 1500);
|
|
}
|
|
|
|
async function handleMatch() {
|
|
setMatching(true);
|
|
|
|
try {
|
|
await api.post(`/projects/${id}/match`);
|
|
} catch (err: any) {
|
|
if (!err.message?.includes('cancel')) {
|
|
alert(`Matching failed: ${err.response?.data?.detail || err.message}`);
|
|
}
|
|
setMatching(false);
|
|
await loadProject();
|
|
return;
|
|
}
|
|
|
|
// Poll until background matching completes (status leaves 'matching')
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const [matchRes, projRes] = await Promise.all([
|
|
api.get(`/projects/${id}/matches`),
|
|
api.get(`/projects/${id}`),
|
|
]);
|
|
setMatches(matchRes.data);
|
|
if (projRes.data.status !== 'matching') {
|
|
clearInterval(pollInterval);
|
|
setMatching(false);
|
|
await loadProject();
|
|
}
|
|
} catch {}
|
|
}, 3000);
|
|
}
|
|
|
|
async function handleCancelMatch() {
|
|
try {
|
|
await api.post(`/projects/${id}/match/cancel`);
|
|
} catch {}
|
|
}
|
|
|
|
async function handleYolo() {
|
|
// For each asset without a selected match, select the rank 1 match
|
|
const unselected = assets.filter(a => {
|
|
const am = matchesByAsset[a.id];
|
|
return am && am.length > 0 && !am.some(m => m.is_selected);
|
|
});
|
|
|
|
// Batch in parallel (chunks of 20)
|
|
for (let i = 0; i < unselected.length; i += 20) {
|
|
const chunk = unselected.slice(i, i + 20);
|
|
await Promise.all(chunk.map(a => {
|
|
const topMatch = matchesByAsset[a.id]?.find(m => m.rank === 1) || matchesByAsset[a.id]?.[0];
|
|
if (topMatch) {
|
|
return api.put(`/projects/${id}/matches/${topMatch.id}/select`, { is_selected: true }).catch(() => {});
|
|
}
|
|
return Promise.resolve();
|
|
}));
|
|
}
|
|
await loadProject();
|
|
}
|
|
|
|
async function loadTeamWithEfficiency(pct: number) {
|
|
setPreviewEfficiency(pct);
|
|
try {
|
|
const res = await api.get(`/projects/${id}/team-shape?efficiency_pct=${pct}`);
|
|
setTeamShape(res.data);
|
|
} catch {}
|
|
}
|
|
|
|
async function loadTeamWithProfile() {
|
|
if (!selectedProfileId) return;
|
|
try {
|
|
const toolIds = Array.from(selectedToolIds).join(',');
|
|
const url = `/projects/${id}/team-shape?profile_id=${selectedProfileId}${toolIds ? `&tool_ids=${toolIds}` : ''}`;
|
|
const res = await api.get(url);
|
|
setTeamShape(res.data);
|
|
setPreviewEfficiency(-1); // -1 = profile mode
|
|
} catch {}
|
|
}
|
|
|
|
function toggleTool(toolId: number) {
|
|
setSelectedToolIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(toolId)) next.delete(toolId);
|
|
else next.add(toolId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
// Reload team shape when profile or tools change
|
|
useEffect(() => {
|
|
if (selectedProfileId && teamShape) {
|
|
loadTeamWithProfile();
|
|
}
|
|
}, [selectedProfileId, selectedToolIds]);
|
|
|
|
function toggleEfficiencyLevel(level: number) {
|
|
setSelectedEfficiencyLevels(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(level)) next.delete(level);
|
|
else next.add(level);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function downloadFile(url: string, filename: string) {
|
|
try {
|
|
const response = await api.get(url, { responseType: 'blob' });
|
|
const blob = new Blob([response.data], { type: response.headers['content-type'] });
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = objectUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(objectUrl);
|
|
} catch (err: any) {
|
|
alert(`Export failed: ${err.response?.data?.detail || err.message}`);
|
|
}
|
|
}
|
|
|
|
function handleExcelExport() {
|
|
const levels = Array.from(selectedEfficiencyLevels).sort().join(',');
|
|
const base = `/projects/${id}/ratecard/export/excel`;
|
|
const url = levels ? `${base}?efficiency_levels=${levels}` : base;
|
|
downloadFile(url, `${project?.name || 'ratecard'}.xlsx`);
|
|
}
|
|
|
|
function handlePdfExport() {
|
|
downloadFile(`/projects/${id}/ratecard/export/pdf`, `${project?.name || 'caveats'}_caveats.pdf`);
|
|
}
|
|
|
|
async function handleAnalyzeBrief(mode: 'file' | 'text') {
|
|
setAnalyzing(true);
|
|
try {
|
|
const payload = mode === 'text' ? { text: briefText } : {};
|
|
const res = await api.post(`/projects/${id}/analyze-brief`, payload);
|
|
setBriefAnalysis(res.data.analysis);
|
|
} catch (err: any) {
|
|
alert(`Analysis failed: ${err.response?.data?.detail || err.message}`);
|
|
} finally {
|
|
setAnalyzing(false);
|
|
}
|
|
}
|
|
|
|
async function handleRefine() {
|
|
if (!refineInput.trim()) return;
|
|
setRefining(true);
|
|
setRefineLog(prev => [...prev, `> ${refineInput}`]);
|
|
try {
|
|
const res = await api.post(`/projects/${id}/refine`, { instruction: refineInput });
|
|
const msg = res.data.message || 'Done.';
|
|
const extra = res.data.rematch_count ? ` Re-matched ${res.data.rematch_count} assets.` : '';
|
|
setRefineLog(prev => [...prev, msg + extra]);
|
|
setRefineInput('');
|
|
await loadProject();
|
|
} catch (err: any) {
|
|
setRefineLog(prev => [...prev, `Error: ${err.response?.data?.detail || err.message}`]);
|
|
} finally {
|
|
setRefining(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!confirm(`Delete project "${project?.name}"? This cannot be undone.`)) return;
|
|
try {
|
|
await api.delete(`/projects/${id}`);
|
|
navigate('/');
|
|
} catch (err: any) {
|
|
alert(`Failed to delete: ${err.response?.data?.detail || err.message}`);
|
|
}
|
|
}
|
|
|
|
async function handleSelectMatch(matchId: number, clientAssetId: number) {
|
|
try {
|
|
await api.put(`/projects/${id}/matches/${matchId}/select`, { is_selected: true });
|
|
// Collapse the group after selection
|
|
setExpandedGroups(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(clientAssetId);
|
|
return next;
|
|
});
|
|
await loadProject();
|
|
} catch (err: any) {
|
|
alert(`Failed: ${err.response?.data?.detail || err.message}`);
|
|
}
|
|
}
|
|
|
|
async function handleBuildRatecard() {
|
|
setBuilding(true);
|
|
try {
|
|
await api.post(`/projects/${id}/ratecard/build`);
|
|
await loadProject();
|
|
setTab('ratecard');
|
|
} catch (err: any) {
|
|
alert(`Build failed: ${err.response?.data?.detail || err.message}`);
|
|
} finally {
|
|
setBuilding(false);
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="loading">Loading...</div>;
|
|
if (!project) return <div className="loading">Project not found</div>;
|
|
|
|
const matchesByAsset: Record<number, Match[]> = {};
|
|
for (const m of matches) {
|
|
if (!matchesByAsset[m.client_asset_id]) matchesByAsset[m.client_asset_id] = [];
|
|
matchesByAsset[m.client_asset_id].push(m);
|
|
}
|
|
|
|
const allAssetsHaveSelectedMatch = assets.length > 0 && assets.every(
|
|
a => matchesByAsset[a.id]?.some(m => m.is_selected)
|
|
);
|
|
|
|
return (
|
|
<div className="project-view">
|
|
<div className="pv-header">
|
|
<div>
|
|
<Link to="/" className="back-link">Back to Projects</Link>
|
|
<h1 className="pv-title">{project.name}</h1>
|
|
<div className="pv-meta">
|
|
{project.client_name && <span className="pv-client">{project.client_name}</span>}
|
|
<span className={`badge ${project.status === 'finalized' ? 'badge-success' : 'badge-muted'}`}>
|
|
{project.status}
|
|
</span>
|
|
<span className="pv-model">{MODEL_TYPE_LABELS[project.model_type]}</span>
|
|
{(project as any).ai_cost_usd > 0 && (
|
|
<span className="pv-cost">
|
|
AI: ${(project as any).ai_cost_usd.toFixed(4)} ({(project as any).ai_call_count} calls)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button onClick={handleDelete} className="btn btn-danger btn-sm">Delete Project</button>
|
|
</div>
|
|
|
|
<div className="tabs">
|
|
{(['upload', 'analysis', 'matches', 'ratecard', 'team'] as Tab[]).map(t => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`tab ${tab === t ? 'tab-active' : ''}`}
|
|
>
|
|
{t === 'upload' ? `Upload & Assets (${assets.length})` :
|
|
t === 'analysis' ? `Brief Analysis${briefAnalysis ? ' ✓' : ''}` :
|
|
t === 'matches' ? `Match Review (${matches.length})` :
|
|
t === 'team' ? `Team Shape${teamShape ? ` (${teamShape.total_fte.toFixed(1)} FTE)` : ''}` :
|
|
'Ratecard'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'upload' && (
|
|
<div className="tab-content">
|
|
<div className={`upload-zone ${uploading ? 'upload-active' : ''}`}>
|
|
{uploading ? (
|
|
<>
|
|
<div className="upload-spinner" />
|
|
<p className="upload-stage">{uploadStage}</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="upload-icon">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg>
|
|
</div>
|
|
<p className="upload-title">Upload Client Document</p>
|
|
<p className="upload-desc">Word (.docx) or Excel (.xlsx) file with the client's asset brief</p>
|
|
<label className="btn btn-primary upload-btn">
|
|
Choose File
|
|
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleUpload} hidden />
|
|
</label>
|
|
{project.source_filename && (
|
|
<p className="upload-file">Current: {project.source_filename}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{assets.length > 0 && (
|
|
<div className="assets-section">
|
|
<h3 className="section-title">Extracted Assets ({assets.length})</h3>
|
|
<div className="asset-list">
|
|
{assets.map(a => (
|
|
<div key={a.id} className="asset-item">
|
|
<div className="asset-name">{a.raw_name}</div>
|
|
{a.raw_description && <div className="asset-desc">{a.raw_description}</div>}
|
|
<div className="asset-vol">Volume: {a.volume}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'analysis' && (
|
|
<div className="tab-content">
|
|
{!briefAnalysis ? (
|
|
<div className="analysis-input-section">
|
|
<p style={{ marginBottom: 16, color: 'var(--color-text-secondary)', fontSize: 13 }}>
|
|
Analyze a client brief to extract structured requirements, identify gaps, and generate discovery questions.
|
|
{project.source_filename && ` You can analyze the uploaded file (${project.source_filename}) or paste a brief below.`}
|
|
</p>
|
|
<textarea
|
|
className="input brief-textarea"
|
|
value={briefText}
|
|
onChange={e => setBriefText(e.target.value)}
|
|
placeholder="Paste the client brief or RFP text here... Or upload a document on the Upload tab first."
|
|
rows={8}
|
|
/>
|
|
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
|
|
{project.source_filename && (
|
|
<button onClick={() => handleAnalyzeBrief('file')} disabled={analyzing} className="btn btn-primary">
|
|
{analyzing ? 'Analyzing...' : `Analyze Uploaded File`}
|
|
</button>
|
|
)}
|
|
<button onClick={() => handleAnalyzeBrief('text')} disabled={analyzing || !briefText.trim()} className="btn btn-primary">
|
|
{analyzing ? 'Analyzing...' : 'Analyze Pasted Text'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="analysis-content">
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Summary</div>
|
|
<div className="analysis-text">{briefAnalysis.summary}</div>
|
|
</div>
|
|
|
|
<div className="analysis-grid">
|
|
{briefAnalysis.objectives?.length > 0 && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Objectives</div>
|
|
<ul className="analysis-list">{briefAnalysis.objectives.map((o: string, i: number) => <li key={i}>{o}</li>)}</ul>
|
|
</div>
|
|
)}
|
|
{briefAnalysis.channels?.length > 0 && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Channels</div>
|
|
<div className="analysis-tags">{briefAnalysis.channels.map((c: string, i: number) => <span key={i} className="analysis-tag">{c}</span>)}</div>
|
|
</div>
|
|
)}
|
|
{briefAnalysis.deliverable_categories?.length > 0 && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Deliverable Categories</div>
|
|
<div className="analysis-tags">{briefAnalysis.deliverable_categories.map((d: string, i: number) => <span key={i} className="analysis-tag">{d}</span>)}</div>
|
|
</div>
|
|
)}
|
|
{briefAnalysis.audiences?.length > 0 && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Audiences</div>
|
|
<ul className="analysis-list">{briefAnalysis.audiences.map((a: string, i: number) => <li key={i}>{a}</li>)}</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{briefAnalysis.constraints?.length > 0 && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Constraints & Requirements</div>
|
|
<ul className="analysis-list">{briefAnalysis.constraints.map((c: string, i: number) => <li key={i}>{c}</li>)}</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="analysis-row">
|
|
{briefAnalysis.complexity_assessment && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Complexity</div>
|
|
<span className={`analysis-complexity analysis-complexity-${briefAnalysis.complexity_assessment}`}>
|
|
{briefAnalysis.complexity_assessment.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{briefAnalysis.timeline && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Timeline</div>
|
|
<div className="analysis-text">{briefAnalysis.timeline}</div>
|
|
</div>
|
|
)}
|
|
{briefAnalysis.budget_band && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Budget</div>
|
|
<div className="analysis-text">{briefAnalysis.budget_band}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{briefAnalysis.missing_info?.length > 0 && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Discovery Questions ({briefAnalysis.missing_info.length})</div>
|
|
<div className="discovery-questions">
|
|
{briefAnalysis.missing_info.map((q: any, i: number) => (
|
|
<div key={i} className={`discovery-q discovery-${q.priority}`}>
|
|
<span className={`q-priority q-${q.priority}`}>{q.priority.toUpperCase()}</span>
|
|
<span className="q-category">{q.category}</span>
|
|
<div className="q-text">{q.question}</div>
|
|
<div className="q-rationale">{q.rationale}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{briefAnalysis.notes && (
|
|
<div className="analysis-section">
|
|
<div className="analysis-label">Notes</div>
|
|
<div className="analysis-text">{briefAnalysis.notes}</div>
|
|
</div>
|
|
)}
|
|
|
|
<button onClick={() => { setBriefAnalysis(null); }} className="btn btn-secondary" style={{ marginTop: 16 }}>
|
|
Re-analyze Brief
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'matches' && (
|
|
<div className="tab-content">
|
|
<div className="match-actions">
|
|
<button onClick={handleMatch} disabled={matching || assets.length === 0} className="btn btn-primary">
|
|
{matching ? `Matching... (${matches.length} found)` : 'Run AI Matching'}
|
|
</button>
|
|
{matching && (
|
|
<button onClick={handleCancelMatch} className="btn btn-danger">
|
|
Stop Matching
|
|
</button>
|
|
)}
|
|
{!allAssetsHaveSelectedMatch && !matching && matches.length > 0 && (
|
|
<button onClick={handleYolo} className="btn btn-yolo">
|
|
YOLO - Select All Top Matches
|
|
</button>
|
|
)}
|
|
{allAssetsHaveSelectedMatch && !matching && (
|
|
<button onClick={handleBuildRatecard} disabled={building} className="btn btn-success">
|
|
{building ? 'Building...' : 'Build Ratecard'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{matches.length > 0 && !matching && (
|
|
<div className="refine-box">
|
|
<div className="refine-log">
|
|
{refineLog.length === 0 && (
|
|
<span className="refine-hint">
|
|
Try: "re-run anything under 70%" / "ignore zero volume" / "set all volumes to 1"
|
|
</span>
|
|
)}
|
|
{refineLog.map((msg, i) => (
|
|
<div key={i} className={msg.startsWith('>') ? 'refine-user' : 'refine-system'}>{msg}</div>
|
|
))}
|
|
</div>
|
|
<div className="refine-input-row">
|
|
<input
|
|
className="input refine-input"
|
|
value={refineInput}
|
|
onChange={e => setRefineInput(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && !refining && handleRefine()}
|
|
placeholder="Type an instruction to refine matches..."
|
|
disabled={refining}
|
|
/>
|
|
<button onClick={handleRefine} disabled={refining || !refineInput.trim()} className="btn btn-primary">
|
|
{refining ? 'Refining...' : 'Send'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{assets.map(a => {
|
|
const assetMatches = matchesByAsset[a.id] || [];
|
|
const selectedMatch = assetMatches.find(m => m.is_selected);
|
|
const hasSelected = !!selectedMatch;
|
|
const isExpanded = expandedGroups.has(a.id);
|
|
const showBody = !hasSelected || isExpanded;
|
|
|
|
function toggleGroup() {
|
|
if (!hasSelected) return;
|
|
setExpandedGroups(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(a.id)) next.delete(a.id);
|
|
else next.add(a.id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div key={a.id} className={`match-group ${hasSelected && !isExpanded ? 'match-group-collapsed' : ''}`}>
|
|
<div
|
|
className="match-group-header"
|
|
onClick={toggleGroup}
|
|
style={{ cursor: hasSelected ? 'pointer' : 'default' }}
|
|
>
|
|
<div className="match-asset-info">
|
|
<div className="match-asset-name-row">
|
|
<span className="match-asset-name">{a.raw_name}</span>
|
|
{hasSelected && !isExpanded && (
|
|
<span className="match-selected-summary">
|
|
<span className={`conf-badge-sm ${CONF_CLASS[selectedMatch.confidence]}`}>
|
|
{Math.round((selectedMatch.confidence_score || 0) * 100)}%
|
|
</span>
|
|
{selectedMatch.gmal_id} - {selectedMatch.gmal_unique_name || selectedMatch.gmal_name}
|
|
</span>
|
|
)}
|
|
{hasSelected && <span className="match-expand-hint">{isExpanded ? 'click to collapse' : 'click to expand'}</span>}
|
|
</div>
|
|
{a.raw_description && (
|
|
<span className="match-asset-desc">{a.raw_description}</span>
|
|
)}
|
|
</div>
|
|
<span className="match-asset-vol">Vol: {a.volume}</span>
|
|
</div>
|
|
|
|
{showBody && (
|
|
<div className="match-group-body">
|
|
{assetMatches.length === 0 ? (
|
|
<div className="match-empty">
|
|
No matches yet. Click "Run AI Matching" to find GMAL equivalents.
|
|
</div>
|
|
) : (
|
|
assetMatches.map(m => (
|
|
<div key={m.id} className={`match-card ${m.is_selected ? 'match-selected' : ''}`}>
|
|
<div className={`match-card-accent ${CONF_CLASS[m.confidence]}`} />
|
|
<div className="match-card-body">
|
|
<div className="match-card-top">
|
|
<div className="match-gmal-info">
|
|
<span className="match-gmal-id">{m.gmal_id}</span>
|
|
<span className="match-gmal-name">{m.gmal_unique_name || m.gmal_name}</span>
|
|
</div>
|
|
<div className="match-card-actions">
|
|
<span className={`conf-badge ${CONF_CLASS[m.confidence]}`}>
|
|
{m.confidence} {m.confidence_score ? `${Math.round(m.confidence_score * 100)}%` : ''}
|
|
</span>
|
|
{!m.is_selected ? (
|
|
<button onClick={(e) => { e.stopPropagation(); handleSelectMatch(m.id, a.id); }} className="btn-select">
|
|
Select
|
|
</button>
|
|
) : (
|
|
<span className="selected-label">Selected</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{m.ai_reasoning && (
|
|
<div className="match-reasoning">
|
|
<strong>Reasoning:</strong> {m.ai_reasoning}
|
|
</div>
|
|
)}
|
|
{m.caveat_text && (
|
|
<div className="match-caveat">
|
|
<strong>Caveats:</strong> {m.caveat_text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'ratecard' && (
|
|
<div className="tab-content">
|
|
{!ratecard || ratecard.lines.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: 40 }}>
|
|
No ratecard built yet. Complete matching and click "Build Ratecard".
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="rc-header">
|
|
<div className="rc-stats">
|
|
<span className="rc-total">Total Hours: {ratecard.total_hours.toLocaleString()}</span>
|
|
<span className="rc-assets">{ratecard.total_assets} assets</span>
|
|
</div>
|
|
<div className="rc-exports">
|
|
<button onClick={handleExcelExport} className="btn btn-secondary">
|
|
Export Excel
|
|
</button>
|
|
<button onClick={handlePdfExport} className="btn btn-secondary">
|
|
Export PDF Caveats
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="table-wrap">
|
|
<table className="rc-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Discipline</th>
|
|
<th>Role</th>
|
|
<th>Asset</th>
|
|
<th>GMAL</th>
|
|
<th className="text-right">Base Hrs</th>
|
|
<th className="text-center">Vol</th>
|
|
<th className="text-right">Total Hrs</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ratecard.lines.map(l => (
|
|
<tr key={l.id}>
|
|
<td className="td-discipline">{l.discipline}</td>
|
|
<td>{l.role_title}</td>
|
|
<td>{l.client_asset_name}</td>
|
|
<td className="td-gmal">{l.gmal_id}</td>
|
|
<td className="text-right">{l.base_hours?.toFixed(2)}</td>
|
|
<td className="text-center">{l.volume}</td>
|
|
<td className="text-right td-total">
|
|
{(l.manual_override ?? l.total_hours)?.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'team' && (
|
|
<div className="tab-content">
|
|
{!teamShape || teamShape.roles.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: 40 }}>
|
|
No team shape data. Build the ratecard first.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Efficiency Controls */}
|
|
<div className="efficiency-controls">
|
|
<div className="efficiency-row">
|
|
<span className="efficiency-label">Efficiency Profile:</span>
|
|
<div className="efficiency-buttons">
|
|
<button
|
|
onClick={() => { setSelectedProfileId(null); loadTeamWithEfficiency(0); }}
|
|
className={`eff-btn ${!selectedProfileId && previewEfficiency === 0 ? 'eff-btn-active' : ''}`}
|
|
>None</button>
|
|
{profiles.map(p => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => setSelectedProfileId(p.id)}
|
|
className={`eff-btn ${selectedProfileId === p.id ? 'eff-btn-active' : ''}`}
|
|
>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedProfileId && (
|
|
<div className="efficiency-row">
|
|
<span className="efficiency-label">BTG Tools:</span>
|
|
<div className="efficiency-buttons">
|
|
{tools.map(t => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => toggleTool(t.id)}
|
|
className={`eff-btn ${selectedToolIds.has(t.id) ? 'eff-btn-selected' : ''}`}
|
|
title={t.tool_description}
|
|
>
|
|
{t.tool_name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedProfileId && (
|
|
<div className="efficiency-row">
|
|
<span className="efficiency-label">Per-discipline rates:</span>
|
|
<div className="discipline-rates">
|
|
{(() => {
|
|
const profile = profiles.find(p => p.id === selectedProfileId);
|
|
if (!profile) return null;
|
|
// Combine profile + tool rates
|
|
const combined: Record<string, number> = { ...profile.rates };
|
|
for (const tid of selectedToolIds) {
|
|
const tool = tools.find(t => t.id === tid);
|
|
if (tool) {
|
|
for (const [disc, pct] of Object.entries(tool.rates)) {
|
|
combined[disc] = Math.min(90, (combined[disc] || 0) + pct);
|
|
}
|
|
}
|
|
}
|
|
return Object.entries(combined)
|
|
.filter(([, pct]) => pct > 0)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([disc, pct]) => (
|
|
<span key={disc} className="disc-rate-tag">
|
|
{disc}: <strong>{pct}%</strong>
|
|
</span>
|
|
));
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="efficiency-row">
|
|
<span className="efficiency-label">Or flat rate:</span>
|
|
<div className="efficiency-buttons">
|
|
{[10, 25, 50, 75, 90].map(pct => (
|
|
<button
|
|
key={pct}
|
|
onClick={() => { setSelectedProfileId(null); loadTeamWithEfficiency(pct); }}
|
|
className={`eff-btn ${!selectedProfileId && previewEfficiency === pct ? 'eff-btn-active' : ''}`}
|
|
>
|
|
{pct}%
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="efficiency-row">
|
|
<span className="efficiency-label">Excel export tabs:</span>
|
|
<div className="efficiency-buttons">
|
|
{[10, 25, 50, 75, 90].map(pct => (
|
|
<button
|
|
key={pct}
|
|
onClick={() => toggleEfficiencyLevel(pct)}
|
|
className={`eff-btn ${selectedEfficiencyLevels.has(pct) ? 'eff-btn-selected' : ''}`}
|
|
>
|
|
{pct}%
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="team-summary">
|
|
<div className="team-stat">
|
|
<div className="team-stat-value">{teamShape.total_fte.toFixed(2)}</div>
|
|
<div className="team-stat-label">Original FTE</div>
|
|
</div>
|
|
{(previewEfficiency !== 0 || selectedProfileId) && (
|
|
<div className="team-stat team-stat-highlight">
|
|
<div className="team-stat-value">{(teamShape as any).adjusted_fte?.toFixed(2) || teamShape.total_fte.toFixed(2)}</div>
|
|
<div className="team-stat-label">Adjusted FTE</div>
|
|
</div>
|
|
)}
|
|
{(previewEfficiency !== 0 || selectedProfileId) && (
|
|
<div className="team-stat team-stat-saved">
|
|
<div className="team-stat-value">{(teamShape as any).fte_saved?.toFixed(2) || '0'}</div>
|
|
<div className="team-stat-label">FTE Saved</div>
|
|
</div>
|
|
)}
|
|
<div className="team-stat">
|
|
<div className="team-stat-value">{teamShape.delivery_fte.toFixed(2)}</div>
|
|
<div className="team-stat-label">Delivery FTE</div>
|
|
</div>
|
|
<div className="team-stat">
|
|
<div className="team-stat-value">{teamShape.programme_fte.toFixed(2)}</div>
|
|
<div className="team-stat-label">Programme FTE</div>
|
|
</div>
|
|
<div className="team-stat">
|
|
<div className="team-stat-value">{((previewEfficiency !== 0 || selectedProfileId) ? (teamShape as any).adjusted_hours : teamShape.total_hours)?.toLocaleString()}</div>
|
|
<div className="team-stat-label">{(previewEfficiency !== 0 || selectedProfileId) ? 'Adjusted' : 'Total'} Hours</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<div className="rc-header" style={{ marginBottom: 16 }}>
|
|
<div />
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button onClick={handleExcelExport} className="btn btn-secondary">
|
|
Export Excel {selectedEfficiencyLevels.size > 0 ? `(+${selectedEfficiencyLevels.size} AI tabs)` : ''}
|
|
</button>
|
|
<button onClick={handlePdfExport} className="btn btn-secondary">
|
|
Export PDF Caveats
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{(() => { const hasEfficiency = previewEfficiency !== 0 || !!selectedProfileId; return (<>
|
|
<div className="table-wrap">
|
|
<table className="rc-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Discipline</th>
|
|
<th>Role</th>
|
|
<th>Type</th>
|
|
<th className="text-right">Hours</th>
|
|
<th className="text-right">FTE</th>
|
|
{hasEfficiency && <th className="text-right">Eff %</th>}
|
|
{hasEfficiency && <th className="text-right">Adj Hours</th>}
|
|
{hasEfficiency && <th className="text-right">Adj FTE</th>}
|
|
<th className="text-right">Headcount</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{teamShape.roles.map((r: any, idx: number) => {
|
|
const prevDisc = idx > 0 ? teamShape.roles[idx - 1].discipline : null;
|
|
const showDiscipline = r.discipline !== prevDisc;
|
|
const displayFte = hasEfficiency ? (r.adjusted_fte ?? r.fte) : r.fte;
|
|
const headcount = displayFte >= 0.5 ? Math.ceil(displayFte) : displayFte > 0 ? 0.5 : 0;
|
|
const cols = hasEfficiency ? 9 : 6;
|
|
return (
|
|
<React.Fragment key={r.role_id}>
|
|
{showDiscipline && (
|
|
<tr className="disc-row">
|
|
<td colSpan={cols} className="td-disc-header">{r.discipline}</td>
|
|
</tr>
|
|
)}
|
|
<tr className={r.is_programme_role ? 'programme-row' : ''}>
|
|
<td className="td-discipline">{r.discipline}</td>
|
|
<td>{r.role_title}</td>
|
|
<td className={r.is_programme_role ? 'td-programme' : 'td-delivery'}>
|
|
{r.is_programme_role ? 'Programme' : 'Delivery'}
|
|
</td>
|
|
<td className="text-right">{r.total_hours.toFixed(2)}</td>
|
|
<td className="text-right">{r.fte.toFixed(2)}</td>
|
|
{hasEfficiency && (
|
|
<td className="text-right td-eff-pct">{(r.efficiency_pct ?? 0).toFixed(0)}%</td>
|
|
)}
|
|
{hasEfficiency && (
|
|
<td className="text-right">{(r.adjusted_hours ?? r.total_hours).toFixed(2)}</td>
|
|
)}
|
|
{hasEfficiency && (
|
|
<td className={`text-right ${r.fte_saved > 0 ? 'td-fte-highlight' : ''}`}>
|
|
{(r.adjusted_fte ?? r.fte).toFixed(2)}
|
|
</td>
|
|
)}
|
|
<td className="text-right td-total">{headcount.toFixed(1)}</td>
|
|
</tr>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>); })()}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|