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:
wmj
2026-08-26 12:27:08 +08:00
parent 2f3e5540c8
commit d793c40d10
15 changed files with 2492 additions and 651 deletions
+184
View File
@@ -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>
);
}