rebuild: complete redesign — enterprise coaching ops platform
- Full redesign for Agile/DT/Enterprise coach personas - Light McKinsey-style UI: white + navy accent, Inter font - Marcus: Team Portfolio (14 teams, filters, sparklines, maturity) - Marcus: Team Detail (maturity bar, session log, action tracker) - Marcus: Session Capture (tagging toolbar, AI coaching questions, AI summary) - Priya: Workshop List + Live Capture (Decision/Tension/Question tags) - Priya: Workshop Summary (AI-generated, grouped by capture type) - David: Portfolio Reports + CEO Report Generator (Gemini-powered) - AI Assistant: 5 modes (Questions/Prep/Workshop/Summary/CEO Report) - Playbooks: Agile/DT/Facilitation library with filters - Persona switcher: Marcus / Priya / David - Netlify function: /api/gemini (server-side API proxy) - netlify.toml: functions dir + API route redirect
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Filter, ArrowUpDown, ChevronRight } from 'lucide-react';
|
||||
import { TEAMS } from '../data/mock.js';
|
||||
|
||||
const BU_LIST = ['All', 'Digital Products', 'Network Operations', 'Customer Experience'];
|
||||
const STATUS_LIST = ['All', 'healthy', 'at-risk', 'blocked'];
|
||||
const MATURITY_LIST = ['All', 'Foundation', 'Developing', 'Performing', 'Leading'];
|
||||
|
||||
const STATUS_STYLE = {
|
||||
healthy: { dot: 'bg-green-500', badge: 'bg-green-50 text-green-700', label: 'Healthy' },
|
||||
'at-risk':{ dot: 'bg-amber-400', badge: 'bg-amber-50 text-amber-700', label: 'At Risk' },
|
||||
blocked: { dot: 'bg-red-500', badge: 'bg-red-50 text-red-600', label: 'Blocked' },
|
||||
};
|
||||
|
||||
const MATURITY_COLOR = {
|
||||
Foundation: 'bg-gray-100 text-gray-600',
|
||||
Developing: 'bg-blue-50 text-blue-700',
|
||||
Performing: 'bg-emerald-50 text-emerald-700',
|
||||
Leading: 'bg-navy-50 text-navy-700',
|
||||
};
|
||||
|
||||
/* Mini sparkline — SVG */
|
||||
function Sparkline({ history, size = 40 }) {
|
||||
if (!history || history.length < 2) return null;
|
||||
const max = 4, min = 0;
|
||||
const w = size, h = 20;
|
||||
const pts = history.map((p, i) => {
|
||||
const x = (i / (history.length - 1)) * w;
|
||||
const y = h - ((p.score - min) / (max - min)) * h;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
const lastScore = history[history.length - 1].score;
|
||||
const firstScore = history[0].score;
|
||||
const trending = lastScore >= firstScore;
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="shrink-0">
|
||||
<polyline points={pts} fill="none" stroke={trending ? '#16a34a' : '#ef4444'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRow({ team, navigate }) {
|
||||
const s = STATUS_STYLE[team.status] || STATUS_STYLE.healthy;
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate('team', { team })}
|
||||
className="w-full flex items-center gap-4 px-5 py-3.5 hover:bg-gray-50 transition-colors text-left group"
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
||||
|
||||
{/* Team name + BU */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 group-hover:text-navy-700 transition-colors">{team.name}</div>
|
||||
<div className="text-xs text-gray-400">{team.bu} · {team.members} members</div>
|
||||
</div>
|
||||
|
||||
{/* Maturity */}
|
||||
<div className="hidden sm:block w-28 shrink-0">
|
||||
<span className={`text-[11px] font-medium px-2 py-0.5 rounded ${MATURITY_COLOR[team.maturityLevel]}`}>
|
||||
{team.maturityLevel}
|
||||
</span>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">{team.maturityScore.toFixed(1)} / 4.0</div>
|
||||
</div>
|
||||
|
||||
{/* Sparkline */}
|
||||
<div className="hidden md:block shrink-0">
|
||||
<Sparkline history={team.maturityHistory} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden sm:block w-20 text-right shrink-0">
|
||||
{team.openActions > 0
|
||||
? <span className={`text-xs font-medium ${team.openActions > 4 ? 'text-red-600' : 'text-amber-600'}`}>{team.openActions} actions</span>
|
||||
: <span className="text-xs text-gray-300">—</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Last session */}
|
||||
<div className="hidden lg:block w-24 text-right text-xs text-gray-400 shrink-0">
|
||||
{team.lastSession}
|
||||
</div>
|
||||
|
||||
<ChevronRight size={14} className="text-gray-300 group-hover:text-navy-700 transition-colors shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamPortfolio({ engagement, navigate }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [bu, setBu] = useState('All');
|
||||
const [status, setStatus] = useState('All');
|
||||
const [maturity, setMaturity] = useState('All');
|
||||
const [sortBy, setSortBy] = useState('status');
|
||||
|
||||
let teams = TEAMS;
|
||||
if (search) teams = teams.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || t.bu.toLowerCase().includes(search.toLowerCase()));
|
||||
if (bu !== 'All') teams = teams.filter(t => t.bu === bu);
|
||||
if (status !== 'All') teams = teams.filter(t => t.status === status);
|
||||
if (maturity !== 'All') teams = teams.filter(t => t.maturityLevel === maturity);
|
||||
|
||||
const sortFns = {
|
||||
status: (a, b) => { const o = { blocked: 0, 'at-risk': 1, healthy: 2 }; return (o[a.status]??3) - (o[b.status]??3); },
|
||||
name: (a, b) => a.name.localeCompare(b.name),
|
||||
maturity: (a, b) => a.maturityScore - b.maturityScore,
|
||||
actions: (a, b) => b.openActions - a.openActions,
|
||||
session: (a, b) => new Date(b.lastSession) - new Date(a.lastSession),
|
||||
};
|
||||
teams = [...teams].sort(sortFns[sortBy] || sortFns.status);
|
||||
|
||||
const counts = { blocked: TEAMS.filter(t=>t.status==='blocked').length, 'at-risk': TEAMS.filter(t=>t.status==='at-risk').length, healthy: TEAMS.filter(t=>t.status==='healthy').length };
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-5xl mx-auto animate-fade-in space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Team Portfolio</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{engagement?.client} — {TEAMS.length} teams across 3 Business Units</p>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs shrink-0">
|
||||
<span className="flex items-center gap-1.5 bg-red-50 text-red-600 px-2.5 py-1.5 rounded-md font-medium"><span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" />{counts.blocked} Blocked</span>
|
||||
<span className="flex items-center gap-1.5 bg-amber-50 text-amber-600 px-2.5 py-1.5 rounded-md font-medium"><span className="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block" />{counts['at-risk']} At Risk</span>
|
||||
<span className="flex items-center gap-1.5 bg-green-50 text-green-700 px-2.5 py-1.5 rounded-md font-medium"><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />{counts.healthy} Healthy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[180px]">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search teams…"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-navy-700 focus:border-navy-700 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
{[
|
||||
{ label: 'BU', options: BU_LIST, value: bu, set: setBu },
|
||||
{ label: 'Status', options: STATUS_LIST, value: status, set: setStatus },
|
||||
{ label: 'Maturity', options: MATURITY_LIST, value: maturity, set: setMaturity },
|
||||
].map(({ label, options, value, set }) => (
|
||||
<select key={label} value={value} onChange={e => set(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-md px-3 py-2 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-navy-700 cursor-pointer">
|
||||
{options.map(o => <option key={o}>{o === 'All' ? `${label}: All` : o}</option>)}
|
||||
</select>
|
||||
))}
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-md px-3 py-2 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-navy-700 cursor-pointer">
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="maturity">Sort: Maturity</option>
|
||||
<option value="actions">Sort: Actions</option>
|
||||
<option value="session">Sort: Last Session</option>
|
||||
<option value="name">Sort: Name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* Table header */}
|
||||
<div className="hidden sm:flex items-center gap-4 px-5 py-3 border-b border-gray-100 text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
<div className="w-2 shrink-0" />
|
||||
<div className="flex-1">Team / BU</div>
|
||||
<div className="w-28 shrink-0">Maturity</div>
|
||||
<div className="hidden md:block w-12 shrink-0">Trend</div>
|
||||
<div className="hidden sm:block w-20 text-right shrink-0">Actions</div>
|
||||
<div className="hidden lg:block w-24 text-right shrink-0">Last Session</div>
|
||||
<div className="w-4 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-gray-50">
|
||||
{teams.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center text-sm text-gray-400">No teams match your filters.</div>
|
||||
) : (
|
||||
teams.map(team => <TeamRow key={team.id} team={team} navigate={navigate} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">Showing {teams.length} of {TEAMS.length} teams</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user