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:
+5
-4
@@ -5,11 +5,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Falah Coach Portal</title>
|
<title>Falah Coach Portal</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<meta name="description" content="Professional coaching operations platform for enterprise coaches." />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black text-slate-100 font-sans antialiased selection:bg-emerald-500/30">
|
<body class="bg-gray-50 text-gray-900 font-sans antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
[build]
|
[build]
|
||||||
command = "npm run build"
|
command = "npm run build"
|
||||||
publish = "dist"
|
publish = "dist"
|
||||||
|
functions = "netlify/functions"
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/api/*"
|
||||||
|
to = "/.netlify/functions/:splat"
|
||||||
|
status = 200
|
||||||
|
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
from = "/*"
|
from = "/*"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export default async (req, context) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { prompt, context: ctx, systemPrompt } = await req.json();
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY || 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
||||||
|
|
||||||
|
const system = systemPrompt || 'You are an expert agile and enterprise coaching assistant for Falah Consulting. Be concise, practical, and outcome-focused.';
|
||||||
|
const fullPrompt = ctx ? `Context: ${ctx}\n\n${prompt}` : prompt;
|
||||||
|
|
||||||
|
const geminiRes = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: system }] },
|
||||||
|
contents: [{ role: 'user', parts: [{ text: fullPrompt }] }],
|
||||||
|
generationConfig: { temperature: 0.7, maxOutputTokens: 1024 },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!geminiRes.ok) {
|
||||||
|
const err = await geminiRes.json();
|
||||||
|
throw new Error(err?.error?.message || `Gemini HTTP ${geminiRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await geminiRes.json();
|
||||||
|
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ text }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return new Response(JSON.stringify({ error: err.message }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = { path: '/api/gemini' };
|
||||||
+182
-625
@@ -1,650 +1,207 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard, Users, Zap, BookOpen, BarChart2,
|
||||||
MessageSquareQuote,
|
ChevronDown, LogOut, Menu, X, BrainCircuit,
|
||||||
Headphones,
|
|
||||||
BookOpen,
|
|
||||||
PlayCircle,
|
|
||||||
PauseCircle,
|
|
||||||
SkipForward,
|
|
||||||
SkipBack,
|
|
||||||
TerminalSquare,
|
|
||||||
Bot,
|
|
||||||
User,
|
|
||||||
Zap,
|
|
||||||
TrendingUp,
|
|
||||||
FileText,
|
|
||||||
ChevronRight,
|
|
||||||
FolderOpen,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
Loader2,
|
|
||||||
Volume2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { PERSONAS, ENGAGEMENTS } from './data/mock.js';
|
||||||
|
import Dashboard from './views/Dashboard.jsx';
|
||||||
|
import TeamPortfolio from './views/TeamPortfolio.jsx';
|
||||||
|
import TeamDetail from './views/TeamDetail.jsx';
|
||||||
|
import SessionCapture from './views/SessionCapture.jsx';
|
||||||
|
import AIAssistant from './views/AIAssistant.jsx';
|
||||||
|
import Playbooks from './views/Playbooks.jsx';
|
||||||
|
import WorkshopList from './views/WorkshopList.jsx';
|
||||||
|
import WorkshopCapture from './views/WorkshopCapture.jsx';
|
||||||
|
import Reports from './views/Reports.jsx';
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ── Nav config ── */
|
||||||
CONFIG
|
const NAV = [
|
||||||
========================================================================== */
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ id: 'portfolio', label: 'Teams', icon: Users },
|
||||||
const GEMINI_API_KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
{ id: 'workshops', label: 'Workshops', icon: Zap },
|
||||||
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`;
|
{ id: 'ai', label: 'AI Assistant', icon: BrainCircuit },
|
||||||
|
{ id: 'playbooks', label: 'Playbooks', icon: BookOpen },
|
||||||
const AI_SYSTEM_PROMPT = `You are a hostile, senior executive playing a roleplay simulation for management consultants at Falah Consulting.
|
{ id: 'reports', label: 'Portfolio', icon: BarChart2 },
|
||||||
Your persona: a hard-nosed C-suite executive (CIO, CFO, or CEO) who is deeply sceptical of consultants and demands hard financial evidence.
|
|
||||||
Rules:
|
|
||||||
- Stay in character as the hostile executive. Never break character.
|
|
||||||
- Challenge every claim with "So what?", "Prove it financially", or "What's the EBITDA impact?"
|
|
||||||
- Be concise — max 3 sentences per reply.
|
|
||||||
- If the user uses buzzwords without data, push back aggressively.
|
|
||||||
- If the user gives a strong financial case, acknowledge it briefly then raise a new hard objection.
|
|
||||||
- Do not explain what you are doing or use meta-commentary.`;
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
MOCK DATA
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
const MOCK_AUDIO_TRACKS = [
|
|
||||||
{
|
|
||||||
id: 1, title: 'The 100-Day Turnaround', duration: '14:20', tag: 'Strategy', author: 'Senior Partner A',
|
|
||||||
ttsScript: `Welcome to the 100-Day Turnaround briefing. In a private equity turnaround, the first 100 days are existential. You must identify the three core value levers: cost, revenue, and working capital. In the first two weeks, conduct a rapid diagnostic: interview 20 key stakeholders, map the P&L to operational drivers, and identify the top 5 EBITDA improvement opportunities. By day 30, present a fully costed transformation roadmap with clear accountability. Prioritise quick wins — at least 3 million in annualised savings within 60 days — to build credibility with the board. The cardinal rule: never surprise your sponsor. Weekly steering updates, always leading with financial metrics. Thank you for listening.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2, title: 'Negotiating with Hostile Boards', duration: '22:15', tag: 'Leadership', author: 'Principal B',
|
|
||||||
ttsScript: `This briefing covers negotiating with hostile boards. A hostile board is not an obstacle — it is a data source. Their aggression tells you exactly where the credibility gap is. Rule one: never walk into a board session without pre-reads reviewed by at least 2 board members in advance. Rule two: lead with the number they care most about — usually free cash flow or EBITDA margin. Rule three: when challenged, do not defend your analysis. Reframe. Ask: What evidence would change your view? This turns a confrontation into a collaborative hypothesis. Always close with a decision matrix, not a recommendation. Give them the power to choose — between your option and an option you've already stress-tested as inferior. That's the Falah method for hostile board navigation.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3, title: 'M&A Synergy Identification', duration: '18:45', tag: 'Finance', author: 'Director C',
|
|
||||||
ttsScript: `M&A synergy identification is where most deals destroy value. Acquirers over-estimate revenue synergies by 40% and under-estimate integration costs by 30%. Our Falah framework starts with a synergy taxonomy: cost synergies, revenue synergies, and financial synergies. Cost synergies — headcount, procurement, and real estate — are the only ones you should bank on in year one. Revenue synergies require market overlap, sales force alignment, and at least 18 months of integration. The critical discipline is the synergy validation session: every synergy claim must be signed off by the business unit owner, not the deal team. If they won't sign, cut it from the model. Underpromise and overdeliver. That is the path to a successful integration.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4, title: 'Digital Transformation Pitfalls', duration: '31:10', tag: 'Technology', author: 'Principal D',
|
|
||||||
ttsScript: `Digital transformation fails 70% of the time. The cause is almost never technology. It is governance, change management, and unclear ROI definition. Pitfall one: starting with technology before defining the business problem. Always begin with the customer journey or the operational bottleneck. Pitfall two: underinvesting in change management. Allocate at least 20% of your transformation budget to people and process. Pitfall three: measuring outputs instead of outcomes. Replace number of APIs deployed with reduction in order-to-cash cycle time. The Falah digital transformation framework has three phases: diagnose, design, and deploy. In diagnose, map every process that touches the customer. In design, identify the minimum viable change to deliver maximum impact. In deploy, run parallel operations until the new system proves reliability.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5, title: 'Pricing Strategies in Inflation', duration: '12:55', tag: 'Economics', author: 'Partner E',
|
|
||||||
ttsScript: `Pricing in an inflationary environment requires a fundamental shift in mindset. Reactive pricing — simply passing on cost increases — destroys customer relationships. Proactive pricing — anticipating inflation and embedding flexibility in contracts — preserves margin and trust. There are three strategies we deploy at Falah. First, value-based pricing: anchor your price to the economic value you deliver, not your cost base. Second, dynamic pricing tiers: create good, better, best options so customers self-select, protecting your premium tier. Third, contract architecture: build in annual CPI-linked adjustments so price increases feel automatic, not adversarial. The bottom line: every 1% improvement in price realisation drops directly to EBITDA. Pricing is the highest-leverage tool in your margin toolkit.`,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_WIKI_DIRECTORY = [
|
/* ── Persona-aware nav visibility ── */
|
||||||
{ folder: 'Core Frameworks', items: ['Falah MECE Guidelines', 'Value Creation Matrix', 'Go-To-Market Playbook'] },
|
const PERSONA_NAV = {
|
||||||
{ folder: 'Banking Edition', items: ['Retail Bank Cost Reduction', 'Fintech Threat Analysis', 'Regulatory Compliance 2026'] },
|
marcus: ['dashboard', 'portfolio', 'ai', 'playbooks'],
|
||||||
{ folder: 'Telco Edition', items: ['5G Monetization', 'Churn Reduction Models'] },
|
priya: ['dashboard', 'workshops', 'ai', 'playbooks'],
|
||||||
];
|
david: ['dashboard', 'reports', 'ai', 'playbooks'],
|
||||||
|
};
|
||||||
|
|
||||||
/* ==========================================================================
|
export default function App() {
|
||||||
HELPERS
|
const [personaId, setPersonaId] = useState('marcus');
|
||||||
========================================================================== */
|
const [nav, setNav] = useState({ view: 'dashboard', params: {} });
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [personaOpen, setPersonaOpen] = useState(false);
|
||||||
|
|
||||||
function formatTime(secs) {
|
const persona = PERSONAS.find(p => p.id === personaId);
|
||||||
if (!isFinite(secs) || secs < 0) return '00:00';
|
const engagement = persona.activeEngagement
|
||||||
const m = Math.floor(secs / 60).toString().padStart(2, '0');
|
? ENGAGEMENTS.find(e => e.id === persona.activeEngagement)
|
||||||
const s = Math.floor(secs % 60).toString().padStart(2, '0');
|
: null;
|
||||||
return `${m}:${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================================================================
|
const navigate = useCallback((view, params = {}) => {
|
||||||
AI CO-PILOT — Gemini 2.5 Flash
|
setNav({ view, params });
|
||||||
========================================================================== */
|
setSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const INITIAL_MESSAGES = [
|
const visibleNav = NAV.filter(n => PERSONA_NAV[personaId].includes(n.id));
|
||||||
{ role: 'system', text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.' },
|
|
||||||
{ role: 'assistant', text: "I don't have time for buzzwords today. You want £2M of my budget to \"transform\" teams that are already delivering. Where is the hard financial justification? I need EBITDA impact, not velocity charts." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const AICoPilotView = () => {
|
/* ── View router ── */
|
||||||
const [messages, setMessages] = useState(INITIAL_MESSAGES);
|
const renderView = () => {
|
||||||
const [input, setInput] = useState('');
|
const { view, params } = nav;
|
||||||
const [loading, setLoading] = useState(false);
|
switch (view) {
|
||||||
const [error, setError] = useState('');
|
case 'dashboard': return <Dashboard persona={persona} engagement={engagement} navigate={navigate} />;
|
||||||
const chatEndRef = useRef(null);
|
case 'portfolio': return <TeamPortfolio persona={persona} engagement={engagement} navigate={navigate} />;
|
||||||
|
case 'team': return <TeamDetail team={params.team} navigate={navigate} />;
|
||||||
useEffect(() => {
|
case 'session': return <SessionCapture team={params.team} session={params.session} navigate={navigate} personaId={personaId} />;
|
||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
case 'workshops': return <WorkshopList persona={persona} engagement={engagement} navigate={navigate} />;
|
||||||
}, [messages, loading]);
|
case 'workshop-live': return <WorkshopCapture workshop={params.workshop} navigate={navigate} personaId={personaId} />;
|
||||||
|
case 'ai': return <AIAssistant persona={persona} engagement={engagement} navigate={navigate} />;
|
||||||
const buildPayload = (msgs) => ({
|
case 'playbooks': return <Playbooks navigate={navigate} />;
|
||||||
systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
|
case 'reports': return <Reports persona={persona} navigate={navigate} />;
|
||||||
contents: msgs
|
default: return <Dashboard persona={persona} engagement={engagement} navigate={navigate} />;
|
||||||
.filter((m) => m.role !== 'system')
|
|
||||||
.map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.text }] })),
|
|
||||||
generationConfig: { temperature: 0.85, maxOutputTokens: 256 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSend = async (e) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed || loading) return;
|
|
||||||
const next = [...messages, { role: 'user', text: trimmed }];
|
|
||||||
setMessages(next);
|
|
||||||
setInput('');
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const res = await fetch(GEMINI_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(buildPayload(next)),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err?.error?.message || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
||||||
if (!reply) throw new Error('Empty response from Gemini');
|
|
||||||
setMessages((prev) => [...prev, { role: 'assistant', text: reply }]);
|
|
||||||
} catch (err) {
|
|
||||||
setError(`AI error: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const SidebarContent = () => (
|
||||||
<div className="h-full flex flex-col pb-4 animate-fade-in pr-0 md:pr-6">
|
<>
|
||||||
<header className="mb-4 md:mb-6">
|
{/* Logo */}
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">AI Co-Pilot & Simulator</h1>
|
<div className="px-5 py-5 border-b border-gray-100">
|
||||||
<p className="text-slate-400 text-sm">Engage in high-pressure executive roleplay to refine your delivery.</p>
|
<div className="flex items-center gap-2.5">
|
||||||
</header>
|
<div className="w-7 h-7 bg-navy-700 rounded flex items-center justify-center shrink-0">
|
||||||
<div className="flex-1 bg-slate-900 border border-slate-800 rounded-2xl flex flex-col overflow-hidden min-h-0">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
<div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4 md:space-y-6">
|
<path d="M2 11L7 3L12 11" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
{messages.map((msg, i) => (
|
</svg>
|
||||||
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
||||||
{msg.role !== 'user' && (
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${msg.role === 'system' ? 'bg-slate-800 text-slate-400' : 'bg-blue-500/20 text-blue-400'}`}>
|
|
||||||
{msg.role === 'system' ? <TerminalSquare size={16} /> : <Bot size={16} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`max-w-[80%] md:max-w-[70%] p-3 md:p-4 rounded-2xl text-sm md:text-base ${
|
|
||||||
msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-sm'
|
|
||||||
: msg.role === 'system' ? 'bg-slate-800 text-slate-300 font-mono text-xs md:text-sm border border-slate-700'
|
|
||||||
: 'bg-slate-800 text-slate-200 border border-slate-700 rounded-tl-sm'
|
|
||||||
}`}>
|
|
||||||
{msg.text}
|
|
||||||
</div>
|
|
||||||
{msg.role === 'user' && (
|
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-700 text-slate-300 flex items-center justify-center shrink-0">
|
|
||||||
<User size={16} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex gap-3 justify-start">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0"><Bot size={16} /></div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-2xl rounded-tl-sm p-4 flex items-center gap-2">
|
|
||||||
<Loader2 size={16} className="animate-spin text-blue-400" />
|
|
||||||
<span className="text-slate-400 text-sm">Thinking...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="text-red-400 text-xs bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-center">{error}</div>}
|
|
||||||
<div ref={chatEndRef} />
|
|
||||||
</div>
|
|
||||||
<div className="p-3 md:p-4 border-t border-slate-800 bg-slate-900/50">
|
|
||||||
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-none">
|
|
||||||
{['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map((cmd) => (
|
|
||||||
<button key={cmd} onClick={() => setInput(cmd)}
|
|
||||||
className="whitespace-nowrap px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 text-xs font-mono rounded-md border border-slate-700 transition-colors">
|
|
||||||
{cmd}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSend} className="relative">
|
|
||||||
<input type="text" value={input} onChange={(e) => setInput(e.target.value)}
|
|
||||||
placeholder="Deploy a response or command..." disabled={loading}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 md:py-4 pr-12 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-slate-500 disabled:opacity-50 text-sm md:text-base" />
|
|
||||||
<button type="submit" disabled={loading || !input.trim()}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-400 p-2 disabled:opacity-30 transition-colors">
|
|
||||||
{loading ? <Loader2 size={18} className="animate-spin" /> : <Zap size={18} />}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
AUDIO LIBRARY VIEW
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
const AudioLibraryView = ({ setActiveTrack }) => (
|
|
||||||
<div className="animate-fade-in pr-0 md:pr-6">
|
|
||||||
<header className="mb-6 md:mb-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-2">Tactical Audio Briefings</h1>
|
|
||||||
<p className="text-slate-400 text-sm md:text-base">Exclusive micro-learning tracks from Falah's leadership network.</p>
|
|
||||||
</header>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6">
|
|
||||||
{MOCK_AUDIO_TRACKS.map((track) => (
|
|
||||||
<div key={track.id} onClick={() => setActiveTrack(track)}
|
|
||||||
className="group bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-emerald-500/5">
|
|
||||||
<div className="flex justify-between items-start mb-10 md:mb-12">
|
|
||||||
<span className="px-2.5 py-1 bg-slate-800 text-slate-300 text-xs font-semibold rounded-md uppercase tracking-wider">{track.tag}</span>
|
|
||||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<PlayCircle size={24} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg md:text-xl font-semibold text-white mb-2 group-hover:text-emerald-400 transition-colors">{track.title}</h3>
|
<div className="text-xs font-bold tracking-[0.12em] uppercase text-navy-700">Falah</div>
|
||||||
<div className="flex justify-between items-center text-sm text-slate-500">
|
<div className="text-[10px] text-gray-400 tracking-wide leading-none">Coach Portal</div>
|
||||||
<span>{track.author}</span>
|
|
||||||
<span className="flex items-center gap-1 font-mono"><Headphones size={14} /> {track.duration}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{engagement && (
|
||||||
</div>
|
<div className="mt-3 px-2.5 py-2 bg-navy-50 rounded-md">
|
||||||
</div>
|
<div className="text-[10px] text-navy-600 font-semibold uppercase tracking-wider mb-0.5">Active Engagement</div>
|
||||||
);
|
<div className="text-xs font-medium text-navy-700 leading-snug">{engagement.client}</div>
|
||||||
|
<div className="text-[11px] text-navy-500">{engagement.name}</div>
|
||||||
/* ==========================================================================
|
|
||||||
MISSION CONTROL
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
const MissionControlView = ({ setActiveView, setActiveTrack }) => (
|
|
||||||
<div className="animate-fade-in-up pr-0 md:pr-6">
|
|
||||||
<header className="mb-6 md:mb-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">Mission Control</h1>
|
|
||||||
<p className="text-slate-400 text-sm">Welcome back. Your Falah Coach portal is ready.</p>
|
|
||||||
</header>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
|
||||||
<div className="lg:col-span-2 bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-2xl p-6 md:p-8 flex flex-col justify-between min-h-[240px] md:min-h-[280px] relative overflow-hidden">
|
|
||||||
<div className="absolute -right-8 -top-8 w-40 h-40 bg-emerald-500/5 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest mb-4">
|
|
||||||
<Zap size={14} className="fill-emerald-400" /> Featured Briefing
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3">{MOCK_AUDIO_TRACKS[0].title}</h2>
|
|
||||||
<p className="text-slate-400 text-sm md:text-base leading-relaxed mb-6">
|
|
||||||
Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setActiveTrack(MOCK_AUDIO_TRACKS[0])}
|
|
||||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold py-3 px-6 rounded-lg transition-colors w-max text-sm md:text-base">
|
|
||||||
<PlayCircle size={20} className="fill-slate-950 text-slate-950" />
|
|
||||||
Play Briefing ({MOCK_AUDIO_TRACKS[0].duration})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{[
|
|
||||||
{ label: 'AI Simulator', desc: 'Practice hostile boardroom scenarios.', view: 'simulator', icon: MessageSquareQuote },
|
|
||||||
{ label: 'Loop Wiki', desc: 'Review the Falah MECE Guidelines.', view: 'wiki', icon: BookOpen },
|
|
||||||
].map(({ label, desc, view, icon: Icon }) => (
|
|
||||||
<div key={view} onClick={() => setActiveView(view)}
|
|
||||||
className="bg-slate-900 border border-slate-800 hover:border-blue-500/40 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-blue-500/5 flex-1 flex flex-col justify-between">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
|
|
||||||
<Icon size={22} className="text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">{label}</h3>
|
|
||||||
<p className="text-slate-400 text-sm">{desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
WIKI VIEW
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
const MSLoopWikiView = () => {
|
|
||||||
const [activePage, setActivePage] = useState('Falah MECE Guidelines');
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col pb-4 animate-fade-in pr-0 md:pr-6">
|
|
||||||
<header className="mb-4 md:mb-6">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">MS Loop Integration</h1>
|
|
||||||
<p className="text-slate-400 text-sm">Falah Playbooks & Living Intellectual Property.</p>
|
|
||||||
</header>
|
|
||||||
<div className="flex-1 flex flex-col md:flex-row gap-4 md:gap-6 min-h-0 overflow-hidden">
|
|
||||||
<div className="w-full md:w-56 shrink-0 bg-slate-900 border border-slate-800 rounded-2xl p-4 overflow-y-auto">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 ml-2">Directory</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{MOCK_WIKI_DIRECTORY.map((dir, idx) => (
|
|
||||||
<div key={idx}>
|
|
||||||
<div className="flex items-center gap-2 text-slate-300 font-medium text-sm mb-2 px-2">
|
|
||||||
<FolderOpen size={16} className="text-blue-400/70" /> {dir.folder}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{dir.items.map((item) => (
|
|
||||||
<button key={item} onClick={() => setActivePage(item)}
|
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors text-left ${activePage === item ? 'bg-blue-500/10 text-blue-400 font-medium' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}>
|
|
||||||
<FileText size={14} /><span className="truncate">{item}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-white rounded-2xl overflow-hidden flex flex-col shadow-2xl min-h-[300px] md:min-h-0">
|
|
||||||
<div className="bg-slate-100 border-b border-slate-200 p-3 flex items-center gap-4">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-slate-300" /><div className="w-3 h-3 rounded-full bg-slate-300" /><div className="w-3 h-3 rounded-full bg-slate-300" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white border border-slate-200 text-slate-400 text-xs px-3 py-1.5 rounded flex-1 flex items-center shadow-sm max-w-md">
|
|
||||||
<span className="text-slate-400 font-mono text-[11px] truncate">loop.microsoft.com/falah/{activePage.toLowerCase().replace(/ /g, '-')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-6 md:p-12 overflow-y-auto text-slate-800 bg-[#fbfbfb]">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-slate-900 border-b border-slate-200 pb-4">{activePage}</h1>
|
|
||||||
<p className="text-lg leading-relaxed mb-6 text-slate-600">This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
|
||||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-blue-900 mb-2">Key Objective</h4>
|
|
||||||
<p className="text-sm text-blue-800">Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-emerald-900 mb-2">Success Metric</h4>
|
|
||||||
<p className="text-sm text-emerald-800">Achieve client alignment before the first break in the agenda.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-4 mt-8 text-slate-800">1. Executive Summary</h3>
|
|
||||||
<p className="text-slate-600 leading-relaxed mb-4">The imperative here is not just structural elegance, but actionable intelligence. When framing the issue, prioritise the financial delta and the organisational capacity required to capture it.</p>
|
|
||||||
<ul className="space-y-2 list-disc pl-5 text-slate-600 mb-8">
|
|
||||||
<li>Identify the core constraint (Capital, Talent, or Regulatory).</li>
|
|
||||||
<li>Map the adjacent capabilities required.</li>
|
|
||||||
<li>Draft the 100-day execution roadmap.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
MAIN APP — Audio state managed with refs to avoid constant re-renders
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
// ── React state — only what MUST cause re-renders ────────────────────────
|
|
||||||
const [activeView, setActiveView] = useState('missionControl');
|
|
||||||
const [activeTrack, setActiveTrack] = useState(null); // track object | null
|
|
||||||
const [trackIndex, setTrackIndex] = useState(0);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
// ── Refs — updated without triggering re-renders ─────────────────────────
|
|
||||||
const utteranceRef = useRef(null);
|
|
||||||
const ttsIntervalRef = useRef(null);
|
|
||||||
const ttsStartTimeRef = useRef(0);
|
|
||||||
const ttsElapsedRef = useRef(0);
|
|
||||||
const durationRef = useRef(0); // estimated total duration (seconds)
|
|
||||||
const activeTrackRef = useRef(null); // mirrors activeTrack
|
|
||||||
|
|
||||||
// DOM refs for live progress updates (direct DOM writes — no re-render)
|
|
||||||
const desktopProgressRef = useRef(null); // sidebar progress bar fill
|
|
||||||
const mobileProgressRef = useRef(null); // mobile strip progress bar fill
|
|
||||||
const desktopTimestampRef = useRef(null); // "00:25 / 14:20"
|
|
||||||
const mobileTimestampRef = useRef(null); // mobile "Now Playing · 00:25"
|
|
||||||
|
|
||||||
// ── Direct DOM update helper ─────────────────────────────────────────────
|
|
||||||
const updateProgressDOM = useCallback((elapsed) => {
|
|
||||||
const dur = durationRef.current;
|
|
||||||
const pct = dur > 0 ? Math.min((elapsed / dur) * 100, 100) : 0;
|
|
||||||
const tStr = formatTime(elapsed);
|
|
||||||
const track = activeTrackRef.current;
|
|
||||||
|
|
||||||
if (desktopProgressRef.current) desktopProgressRef.current.style.width = `${pct}%`;
|
|
||||||
if (mobileProgressRef.current) mobileProgressRef.current.style.width = `${pct}%`;
|
|
||||||
if (desktopTimestampRef.current) desktopTimestampRef.current.textContent = `${tStr} / ${track?.duration ?? ''}`;
|
|
||||||
if (mobileTimestampRef.current) mobileTimestampRef.current.textContent = tStr;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── TTS helpers ──────────────────────────────────────────────────────────
|
|
||||||
const stopTTS = useCallback(() => {
|
|
||||||
window.speechSynthesis.cancel();
|
|
||||||
clearInterval(ttsIntervalRef.current);
|
|
||||||
utteranceRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startTTS = useCallback((track, fromSeconds = 0) => {
|
|
||||||
if (!track?.ttsScript) return;
|
|
||||||
stopTTS();
|
|
||||||
|
|
||||||
const estimatedDuration = track.ttsScript.length / 12.5;
|
|
||||||
durationRef.current = estimatedDuration;
|
|
||||||
activeTrackRef.current = track;
|
|
||||||
|
|
||||||
const charOffset = Math.floor((fromSeconds / estimatedDuration) * track.ttsScript.length);
|
|
||||||
const scriptSlice = track.ttsScript.slice(charOffset);
|
|
||||||
|
|
||||||
const utt = new SpeechSynthesisUtterance(scriptSlice);
|
|
||||||
utt.rate = 0.92; utt.pitch = 1.0; utt.volume = 1.0;
|
|
||||||
const voices = window.speechSynthesis.getVoices();
|
|
||||||
const preferred = voices.find((v) => v.lang.startsWith('en') && v.name.toLowerCase().includes('google'))
|
|
||||||
|| voices.find((v) => v.lang.startsWith('en')) || null;
|
|
||||||
if (preferred) utt.voice = preferred;
|
|
||||||
|
|
||||||
ttsStartTimeRef.current = Date.now();
|
|
||||||
ttsElapsedRef.current = fromSeconds;
|
|
||||||
|
|
||||||
utt.onend = () => { setIsPlaying(false); ttsElapsedRef.current = 0; clearInterval(ttsIntervalRef.current); updateProgressDOM(0); };
|
|
||||||
utt.onerror = () => { setIsPlaying(false); clearInterval(ttsIntervalRef.current); };
|
|
||||||
|
|
||||||
utteranceRef.current = utt;
|
|
||||||
window.speechSynthesis.speak(utt);
|
|
||||||
setIsPlaying(true);
|
|
||||||
|
|
||||||
// Interval only mutates DOM — NO setState, NO re-render
|
|
||||||
ttsIntervalRef.current = setInterval(() => {
|
|
||||||
const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
|
|
||||||
updateProgressDOM(Math.min(elapsed, estimatedDuration));
|
|
||||||
}, 250);
|
|
||||||
}, [stopTTS, updateProgressDOM]);
|
|
||||||
|
|
||||||
// ── Load a track ─────────────────────────────────────────────────────────
|
|
||||||
const loadTrack = useCallback((track) => {
|
|
||||||
const idx = MOCK_AUDIO_TRACKS.findIndex((t) => t.id === track.id);
|
|
||||||
stopTTS();
|
|
||||||
setActiveTrack(track);
|
|
||||||
setTrackIndex(idx >= 0 ? idx : 0);
|
|
||||||
setIsPlaying(false);
|
|
||||||
ttsElapsedRef.current = 0;
|
|
||||||
durationRef.current = 0;
|
|
||||||
updateProgressDOM(0);
|
|
||||||
setTimeout(() => startTTS(track, 0), 200);
|
|
||||||
}, [stopTTS, startTTS, updateProgressDOM]);
|
|
||||||
|
|
||||||
// ── Play / Pause ──────────────────────────────────────────────────────────
|
|
||||||
const togglePlay = useCallback(() => {
|
|
||||||
if (!activeTrackRef.current) return;
|
|
||||||
if (isPlaying) {
|
|
||||||
ttsElapsedRef.current = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
|
|
||||||
stopTTS();
|
|
||||||
setIsPlaying(false);
|
|
||||||
} else {
|
|
||||||
startTTS(activeTrackRef.current, ttsElapsedRef.current);
|
|
||||||
}
|
|
||||||
}, [isPlaying, stopTTS, startTTS]);
|
|
||||||
|
|
||||||
// ── Skip ─────────────────────────────────────────────────────────────────
|
|
||||||
const skipNext = useCallback(() => {
|
|
||||||
const next = (trackIndex + 1) % MOCK_AUDIO_TRACKS.length;
|
|
||||||
loadTrack(MOCK_AUDIO_TRACKS[next]);
|
|
||||||
}, [trackIndex, loadTrack]);
|
|
||||||
|
|
||||||
const skipPrev = useCallback(() => {
|
|
||||||
const prev = (trackIndex - 1 + MOCK_AUDIO_TRACKS.length) % MOCK_AUDIO_TRACKS.length;
|
|
||||||
loadTrack(MOCK_AUDIO_TRACKS[prev]);
|
|
||||||
}, [trackIndex, loadTrack]);
|
|
||||||
|
|
||||||
// ── Seek ──────────────────────────────────────────────────────────────────
|
|
||||||
const seek = useCallback((e) => {
|
|
||||||
const dur = durationRef.current;
|
|
||||||
if (!activeTrackRef.current || !dur) return;
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
||||||
const target = frac * dur;
|
|
||||||
ttsElapsedRef.current = target;
|
|
||||||
updateProgressDOM(target);
|
|
||||||
if (isPlaying) { stopTTS(); setTimeout(() => startTTS(activeTrackRef.current, target), 100); }
|
|
||||||
}, [isPlaying, stopTTS, startTTS, updateProgressDOM]);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
useEffect(() => () => { stopTTS(); }, [stopTTS]);
|
|
||||||
|
|
||||||
const handleNavClick = (id) => { setActiveView(id); setSidebarOpen(false); };
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard },
|
|
||||||
{ id: 'simulator', label: 'AI Co-Pilot', icon: MessageSquareQuote },
|
|
||||||
{ id: 'audio', label: 'Audio Library', icon: Headphones },
|
|
||||||
{ id: 'wiki', label: 'Loop Wiki', icon: BookOpen },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen bg-slate-950 text-slate-100 font-sans overflow-hidden selection:bg-blue-500/30">
|
|
||||||
|
|
||||||
{/* Mobile backdrop */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div className="fixed inset-0 z-30 bg-black/60 md:hidden" onClick={() => setSidebarOpen(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className={`fixed inset-y-0 left-0 z-40 w-72 border-r border-slate-800 flex flex-col bg-slate-900 shadow-2xl transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}>
|
|
||||||
<div className="p-6 flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
|
||||||
<TrendingUp className="text-slate-950" size={20} strokeWidth={2.5} />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-xl tracking-wide text-white uppercase">FALAH</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] uppercase font-bold tracking-[0.2em] text-slate-500 ml-11">Coach Portal</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-slate-500 hover:text-white p-1 mt-1" aria-label="Close menu">
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 px-4 mt-2 space-y-2 overflow-y-auto">
|
|
||||||
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 ml-3">Platform</div>
|
|
||||||
{navItems.map(({ id, label, icon: Icon }) => {
|
|
||||||
const isActive = activeView === id;
|
|
||||||
return (
|
|
||||||
<button key={id} onClick={() => handleNavClick(id)}
|
|
||||||
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group ${isActive ? 'bg-blue-600 shadow-md shadow-blue-500/20' : 'hover:bg-slate-800 text-slate-400 hover:text-slate-100'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Icon size={18} className={isActive ? 'text-white' : 'text-slate-500 group-hover:text-blue-400 transition-colors'} />
|
|
||||||
<span className={`font-medium ${isActive ? 'text-white' : ''}`}>{label}</span>
|
|
||||||
</div>
|
|
||||||
{isActive && <ChevronRight size={16} className="text-blue-200" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Sidebar audio player — inlined, refs for live progress (no re-render) */}
|
|
||||||
<div className="mt-auto border-t border-slate-800 p-4 bg-slate-950/50 backdrop-blur-md">
|
|
||||||
{activeTrack ? (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1">
|
|
||||||
<Volume2 size={10} /> Now Playing
|
|
||||||
</span>
|
|
||||||
{/* timestamp updated directly via ref — no re-render */}
|
|
||||||
<span ref={desktopTimestampRef} className="text-xs text-slate-500 font-mono">00:00 / {activeTrack.duration}</span>
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold text-sm text-white truncate mb-0.5">{activeTrack.title}</p>
|
|
||||||
<p className="text-xs text-slate-400 mb-3 truncate">{activeTrack.author}</p>
|
|
||||||
{/* Seekable progress bar — fill updated via ref */}
|
|
||||||
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden cursor-pointer mb-3" onClick={seek} title="Click to seek">
|
|
||||||
<div ref={desktopProgressRef} className="bg-emerald-500 h-full rounded-full" style={{ width: '0%' }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between px-2">
|
|
||||||
<button aria-label="Previous track" onClick={skipPrev} className="text-slate-400 hover:text-white transition-colors p-1"><SkipBack size={18} /></button>
|
|
||||||
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
className="text-white bg-emerald-500 hover:bg-emerald-400 rounded-full w-10 h-10 flex items-center justify-center transition-all shadow-lg shadow-emerald-500/20">
|
|
||||||
{isPlaying ? <PauseCircle size={24} className="text-slate-950" /> : <PlayCircle size={24} className="text-slate-950" />}
|
|
||||||
</button>
|
|
||||||
<button aria-label="Next track" onClick={skipNext} className="text-slate-400 hover:text-white transition-colors p-1"><SkipForward size={18} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-4 text-center">
|
|
||||||
<Headphones className="mx-auto text-slate-700 mb-2" size={24} />
|
|
||||||
<p className="text-xs text-slate-500 font-medium">Select an audio brief to begin</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content column */}
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
||||||
|
|
||||||
{/* Mobile top bar */}
|
|
||||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shrink-0 z-20">
|
|
||||||
<button onClick={() => setSidebarOpen(true)} aria-label="Open menu" className="text-slate-400 hover:text-white p-1"><Menu size={22} /></button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-white rounded-md flex items-center justify-center">
|
|
||||||
<TrendingUp className="text-slate-950" size={13} strokeWidth={2.5} />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-sm tracking-wide text-white uppercase">FALAH</span>
|
|
||||||
<span className="text-[9px] uppercase font-bold tracking-widest text-slate-500">Coach Portal</span>
|
|
||||||
</div>
|
|
||||||
{activeTrack ? (
|
|
||||||
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
className="w-8 h-8 bg-emerald-500 hover:bg-emerald-400 rounded-full flex items-center justify-center transition-all">
|
|
||||||
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
|
|
||||||
</button>
|
|
||||||
) : <div className="w-8" />}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Scrollable main */}
|
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-8 relative scroll-smooth overflow-x-hidden">
|
|
||||||
<div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none" />
|
|
||||||
<div className="max-w-6xl mx-auto h-full relative z-10">
|
|
||||||
{activeView === 'missionControl' && <MissionControlView setActiveView={setActiveView} setActiveTrack={loadTrack} />}
|
|
||||||
{activeView === 'simulator' && <AICoPilotView />}
|
|
||||||
{activeView === 'audio' && <AudioLibraryView setActiveTrack={loadTrack} />}
|
|
||||||
{activeView === 'wiki' && <MSLoopWikiView />}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Mobile mini audio strip (only shown when track active) */}
|
|
||||||
{activeTrack && (
|
|
||||||
<div className="md:hidden shrink-0 bg-slate-900 border-t border-emerald-500/30 px-4 py-2 z-20">
|
|
||||||
{/* Mobile progress bar fill updated via ref */}
|
|
||||||
<div className="w-full bg-slate-800 h-1 rounded-full overflow-hidden cursor-pointer mb-2" onClick={seek}>
|
|
||||||
<div ref={mobileProgressRef} className="bg-emerald-500 h-full rounded-full" style={{ width: '0%' }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs font-semibold text-white truncate">{activeTrack.title}</p>
|
|
||||||
<p className="text-[10px] text-emerald-400 font-bold uppercase tracking-wider">
|
|
||||||
{isPlaying ? 'Now Playing' : 'Paused'} · <span ref={mobileTimestampRef}>00:00</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<button aria-label="Previous" onClick={skipPrev} className="text-slate-400 p-1"><SkipBack size={16} /></button>
|
|
||||||
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
className="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center">
|
|
||||||
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
|
|
||||||
</button>
|
|
||||||
<button aria-label="Next" onClick={skipNext} className="text-slate-400 p-1"><SkipForward size={16} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||||
|
{visibleNav.map(({ id, label, icon: Icon }) => {
|
||||||
|
const isActive = nav.view === id || (id === 'portfolio' && nav.view === 'team');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => navigate(id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-navy-50 text-navy-700 border-l-2 border-navy-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 border-l-2 border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} className={isActive ? 'text-navy-700' : 'text-gray-400'} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Persona switcher */}
|
||||||
|
<div className="px-3 py-3 border-t border-gray-100">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setPersonaOpen(p => !p)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-navy-700 text-white rounded-full flex items-center justify-center text-xs font-bold shrink-0">
|
||||||
|
{persona.initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{persona.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 truncate">{persona.role}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={14} className="text-gray-400 shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{personaOpen && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1">
|
||||||
|
<div className="px-3 py-1.5 text-[10px] text-gray-400 font-semibold uppercase tracking-wider">Switch Persona</div>
|
||||||
|
{PERSONAS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => { setPersonaId(p.id); navigate('dashboard'); setPersonaOpen(false); }}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-gray-50 transition-colors ${personaId === p.id ? 'bg-navy-50' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 bg-navy-700 text-white rounded-full flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||||
|
{p.initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{p.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{p.role}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||||
|
{/* ── Desktop sidebar ── */}
|
||||||
|
<aside className="hidden md:flex w-56 shrink-0 border-r border-gray-200 bg-white flex-col">
|
||||||
|
<SidebarContent />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Mobile sidebar backdrop ── */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/30 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Mobile sidebar ── */}
|
||||||
|
<aside className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 flex flex-col transform transition-transform duration-200 md:hidden ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||||
|
<span className="text-sm font-bold text-navy-700 tracking-widest uppercase">Falah</span>
|
||||||
|
<button onClick={() => setSidebarOpen(false)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<SidebarContent />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Main content ── */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
{/* Mobile top bar */}
|
||||||
|
<header className="md:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200 shrink-0">
|
||||||
|
<button onClick={() => setSidebarOpen(true)} className="p-1 text-gray-500">
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-bold text-navy-700 tracking-widest uppercase">Falah Coach Portal</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* View content */}
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{renderView()}
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* Mobile bottom nav */}
|
{/* Mobile bottom nav */}
|
||||||
<nav className="md:hidden shrink-0 bg-slate-900 border-t border-slate-800 flex items-center justify-around px-2 py-2 z-20">
|
<nav className="md:hidden shrink-0 bg-white border-t border-gray-200 flex">
|
||||||
{navItems.map(({ id, label, icon: Icon }) => {
|
{visibleNav.slice(0, 4).map(({ id, label, icon: Icon }) => {
|
||||||
const isActive = activeView === id;
|
const isActive = nav.view === id || (id === 'portfolio' && nav.view === 'team');
|
||||||
return (
|
return (
|
||||||
<button key={id} onClick={() => handleNavClick(id)} aria-label={label}
|
<button key={id} onClick={() => navigate(id)}
|
||||||
className={`flex flex-col items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${isActive ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300'}`}>
|
className={`flex-1 flex flex-col items-center gap-1 py-2 text-[10px] font-medium transition-colors ${isActive ? 'text-navy-700' : 'text-gray-400'}`}>
|
||||||
<Icon size={20} />
|
<Icon size={18} />
|
||||||
<span className="text-[10px] font-medium leading-none">{label.split(' ')[0]}</span>
|
{label.split(' ')[0]}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// ============================================================
|
||||||
|
// FALAH COACH PORTAL — Mock Data
|
||||||
|
// All personas, engagements, teams, sessions, actions, workshops
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const PERSONAS = [
|
||||||
|
{
|
||||||
|
id: 'marcus',
|
||||||
|
name: 'Marcus Chen',
|
||||||
|
role: 'Senior Agile Coach',
|
||||||
|
initials: 'MC',
|
||||||
|
activeEngagement: 'meridian',
|
||||||
|
defaultView: 'dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priya',
|
||||||
|
name: 'Priya Sharma',
|
||||||
|
role: 'Principal DT Coach',
|
||||||
|
initials: 'PS',
|
||||||
|
activeEngagement: 'bankfirst',
|
||||||
|
defaultView: 'dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'david',
|
||||||
|
name: 'David Walsh',
|
||||||
|
role: 'Enterprise Coach · Partner',
|
||||||
|
initials: 'DW',
|
||||||
|
activeEngagement: null,
|
||||||
|
defaultView: 'reports',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENGAGEMENTS = [
|
||||||
|
{
|
||||||
|
id: 'meridian',
|
||||||
|
client: 'Meridian Telco',
|
||||||
|
name: 'SAFe Transformation',
|
||||||
|
phase: 'Scale',
|
||||||
|
startDate: '2025-02-01',
|
||||||
|
endDate: '2026-07-31',
|
||||||
|
industry: 'Telecommunications',
|
||||||
|
coaches: ['marcus', 'david'],
|
||||||
|
teamCount: 14,
|
||||||
|
description: 'Enterprise-wide SAFe 6.0 adoption across 3 Business Units. 14 product teams, ~180 practitioners.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bankfirst',
|
||||||
|
client: 'BankFirst Group',
|
||||||
|
name: 'Digital Transformation Programme',
|
||||||
|
phase: 'Vision & Alignment',
|
||||||
|
startDate: '2025-10-01',
|
||||||
|
endDate: '2026-09-30',
|
||||||
|
industry: 'Financial Services',
|
||||||
|
coaches: ['priya', 'david'],
|
||||||
|
teamCount: 6,
|
||||||
|
description: '12-month executive-led digital transformation. Aligning 6 C-suite stakeholders on a 3-year digital roadmap.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nexus',
|
||||||
|
client: 'Nexus Energy',
|
||||||
|
name: 'Agile at Scale',
|
||||||
|
phase: 'Foundation',
|
||||||
|
startDate: '2026-03-01',
|
||||||
|
endDate: '2027-02-28',
|
||||||
|
industry: 'Energy & Utilities',
|
||||||
|
coaches: ['david'],
|
||||||
|
teamCount: 8,
|
||||||
|
description: 'Lean-Agile mindset and practice adoption across the IT division. 8 teams, 2 ARTs planned.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── TEAMS (14 for Meridian) ──────────────────────────────────
|
||||||
|
|
||||||
|
const MATURITY_LEVELS = ['Foundation', 'Developing', 'Performing', 'Leading'];
|
||||||
|
|
||||||
|
export const TEAMS = [
|
||||||
|
// BU: Digital Products (5 teams)
|
||||||
|
{
|
||||||
|
id: 'tf', name: 'Team Falcon', bu: 'Digital Products', members: 9, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Developing', maturityScore: 2.4, lastSession: '2026-08-15',
|
||||||
|
openActions: 5, status: 'at-risk',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.2 }, { month: 'Apr', score: 1.8 },
|
||||||
|
{ month: 'Jun', score: 2.1 }, { month: 'Aug', score: 2.4 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'th', name: 'Team Horizon', bu: 'Digital Products', members: 7, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Performing', maturityScore: 3.2, lastSession: '2026-08-20',
|
||||||
|
openActions: 2, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.8 }, { month: 'Apr', score: 2.3 },
|
||||||
|
{ month: 'Jun', score: 2.9 }, { month: 'Aug', score: 3.2 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tn', name: 'Team Nova', bu: 'Digital Products', members: 11, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Developing', maturityScore: 2.0, lastSession: '2026-08-10',
|
||||||
|
openActions: 8, status: 'blocked',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.0 }, { month: 'Apr', score: 1.5 },
|
||||||
|
{ month: 'Jun', score: 1.8 }, { month: 'Aug', score: 2.0 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tp', name: 'Team Phoenix', bu: 'Digital Products', members: 8, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Performing', maturityScore: 3.0, lastSession: '2026-08-19',
|
||||||
|
openActions: 1, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 2.0 }, { month: 'Apr', score: 2.4 },
|
||||||
|
{ month: 'Jun', score: 2.8 }, { month: 'Aug', score: 3.0 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ta', name: 'Team Atlas', bu: 'Digital Products', members: 6, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Leading', maturityScore: 3.8, lastSession: '2026-08-21',
|
||||||
|
openActions: 0, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 2.5 }, { month: 'Apr', score: 3.0 },
|
||||||
|
{ month: 'Jun', score: 3.5 }, { month: 'Aug', score: 3.8 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
// BU: Network Operations (5 teams)
|
||||||
|
{
|
||||||
|
id: 'tv', name: 'Team Vortex', bu: 'Network Operations', members: 10, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Foundation', maturityScore: 1.3, lastSession: '2026-07-30',
|
||||||
|
openActions: 9, status: 'blocked',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.0 }, { month: 'Apr', score: 1.1 },
|
||||||
|
{ month: 'Jun', score: 1.2 }, { month: 'Aug', score: 1.3 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'to', name: 'Team Orbit', bu: 'Network Operations', members: 8, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Developing', maturityScore: 2.2, lastSession: '2026-08-18',
|
||||||
|
openActions: 4, status: 'at-risk',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.3 }, { month: 'Apr', score: 1.7 },
|
||||||
|
{ month: 'Jun', score: 2.0 }, { month: 'Aug', score: 2.2 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tq', name: 'Team Quasar', bu: 'Network Operations', members: 9, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Developing', maturityScore: 2.6, lastSession: '2026-08-20',
|
||||||
|
openActions: 3, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.5 }, { month: 'Apr', score: 2.0 },
|
||||||
|
{ month: 'Jun', score: 2.3 }, { month: 'Aug', score: 2.6 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ts', name: 'Team Solstice', bu: 'Network Operations', members: 7, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Performing', maturityScore: 3.1, lastSession: '2026-08-14',
|
||||||
|
openActions: 2, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.9 }, { month: 'Apr', score: 2.4 },
|
||||||
|
{ month: 'Jun', score: 2.8 }, { month: 'Aug', score: 3.1 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'te', name: 'Team Echo', bu: 'Network Operations', members: 11, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Foundation', maturityScore: 1.6, lastSession: '2026-08-05',
|
||||||
|
openActions: 7, status: 'at-risk',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.0 }, { month: 'Apr', score: 1.2 },
|
||||||
|
{ month: 'Jun', score: 1.4 }, { month: 'Aug', score: 1.6 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
// BU: Customer Experience (4 teams)
|
||||||
|
{
|
||||||
|
id: 'tpr', name: 'Team Prism', bu: 'Customer Experience', members: 8, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Performing', maturityScore: 3.4, lastSession: '2026-08-22',
|
||||||
|
openActions: 1, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 2.2 }, { month: 'Apr', score: 2.8 },
|
||||||
|
{ month: 'Jun', score: 3.1 }, { month: 'Aug', score: 3.4 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tze', name: 'Team Zenith', bu: 'Customer Experience', members: 6, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Leading', maturityScore: 3.9, lastSession: '2026-08-21',
|
||||||
|
openActions: 0, status: 'healthy',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 2.8 }, { month: 'Apr', score: 3.2 },
|
||||||
|
{ month: 'Jun', score: 3.6 }, { month: 'Aug', score: 3.9 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tti', name: 'Team Titan', bu: 'Customer Experience', members: 9, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Developing', maturityScore: 2.3, lastSession: '2026-08-16',
|
||||||
|
openActions: 4, status: 'at-risk',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.4 }, { month: 'Apr', score: 1.8 },
|
||||||
|
{ month: 'Jun', score: 2.0 }, { month: 'Aug', score: 2.3 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpu', name: 'Team Pulse', bu: 'Customer Experience', members: 7, assignedCoach: 'marcus',
|
||||||
|
maturityLevel: 'Foundation', maturityScore: 1.5, lastSession: '2026-08-08',
|
||||||
|
openActions: 6, status: 'at-risk',
|
||||||
|
maturityHistory: [
|
||||||
|
{ month: 'Feb', score: 1.0 }, { month: 'Apr', score: 1.1 },
|
||||||
|
{ month: 'Jun', score: 1.3 }, { month: 'Aug', score: 1.5 },
|
||||||
|
],
|
||||||
|
sprint: 'PI 4 / Sprint 3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── SESSIONS ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SESSIONS = [
|
||||||
|
{
|
||||||
|
id: 's1', teamId: 'tf', date: '2026-08-15',
|
||||||
|
coach: 'Marcus Chen', type: 'Sprint Retrospective Coaching', duration: 90,
|
||||||
|
aiSummary: 'Team Falcon continues to struggle with PO empowerment — the Product Owner lacks authority to make scope decisions independently. Three overdue actions from the previous session remain open, suggesting follow-through is a systemic issue. The team\'s velocity has stabilised but innovation capacity is limited by persistent tech debt. Recommended next intervention: a focused PO empowerment workshop with Meridian leadership.',
|
||||||
|
tags: [
|
||||||
|
{ type: 'blocker', text: 'PO lacks authority to make scope decisions without VP approval' },
|
||||||
|
{ type: 'action', text: 'Facilitate PO empowerment workshop with Digital Products VP', owner: 'Marcus', due: '2026-08-29', status: 'open' },
|
||||||
|
{ type: 'action', text: 'Team to document top 5 tech debt items and present to architect', owner: 'Team Lead', due: '2026-08-22', status: 'overdue' },
|
||||||
|
{ type: 'decision', text: 'Move to 3-week sprints to accommodate dependency management' },
|
||||||
|
{ type: 'question', text: 'Is the tech debt backlog visible to the ART and prioritised at PI level?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2', teamId: 'tf', date: '2026-08-01',
|
||||||
|
coach: 'Marcus Chen', type: 'Team Health Check', duration: 60,
|
||||||
|
aiSummary: 'Health check revealed low psychological safety scores — team members hesitant to raise impediments in open forums. Retrospective format is too structured; recommend switching to a more freeform approach to surface real issues. PO absence from daily standups is noted as a key blocker.',
|
||||||
|
tags: [
|
||||||
|
{ type: 'blocker', text: 'PO frequently absent from daily standup — team lacks direction' },
|
||||||
|
{ type: 'action', text: 'Schedule 1:1 with PO to understand workload and attendance constraints', owner: 'Marcus', due: '2026-08-08', status: 'done' },
|
||||||
|
{ type: 'decision', text: 'Switch to "4Ls" retro format next sprint' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's3', teamId: 'tn', date: '2026-08-10',
|
||||||
|
coach: 'Marcus Chen', type: 'Impediment Removal Workshop', duration: 120,
|
||||||
|
aiSummary: 'Team Nova\'s primary blocker is an architectural dependency on a shared platform team that has a 6-week backlog. This is causing significant flow disruption. The team has 8 open actions, many inherited from previous coaches. Recommend a full action backlog review and ruthless deprioritisation.',
|
||||||
|
tags: [
|
||||||
|
{ type: 'blocker', text: 'Platform team dependency — 6-week backlog causing complete flow blockage' },
|
||||||
|
{ type: 'tension', text: 'Engineering Manager wants to split team; SM disagrees' },
|
||||||
|
{ type: 'action', text: 'Escalate platform dependency to ART level at next System Demo', owner: 'Marcus', due: '2026-08-24', status: 'open' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── ACTIONS ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ACTIONS = [
|
||||||
|
{ id: 'a1', teamId: 'tf', sessionId: 's1', text: 'Facilitate PO empowerment workshop with Digital Products VP', owner: 'Marcus', dueDate: '2026-08-29', status: 'open' },
|
||||||
|
{ id: 'a2', teamId: 'tf', sessionId: 's1', text: 'Document top 5 tech debt items and present to architect', owner: 'Team Lead', dueDate: '2026-08-22', status: 'overdue' },
|
||||||
|
{ id: 'a3', teamId: 'tf', sessionId: 's1', text: 'Review Definition of Done with entire team', owner: 'Scrum Master', dueDate: '2026-08-25', status: 'open' },
|
||||||
|
{ id: 'a4', teamId: 'tf', sessionId: 's1', text: 'Present velocity trend to Programme Manager', owner: 'Marcus', dueDate: '2026-09-01', status: 'open' },
|
||||||
|
{ id: 'a5', teamId: 'tf', sessionId: 's2', text: 'Schedule 1:1 with PO on workload and attendance', owner: 'Marcus', dueDate: '2026-08-08', status: 'done' },
|
||||||
|
{ id: 'a6', teamId: 'tn', sessionId: 's3', text: 'Escalate platform dependency to ART level at System Demo', owner: 'Marcus', dueDate: '2026-08-24', status: 'open' },
|
||||||
|
{ id: 'a7', teamId: 'tn', sessionId: 's3', text: 'Deprioritise and close 4 inherited actions over 60 days old', owner: 'Scrum Master', dueDate: '2026-08-17', status: 'overdue' },
|
||||||
|
{ id: 'a8', teamId: 'tv', sessionId: null, text: 'Run Agile basics workshop — team has no Scrum experience', owner: 'Marcus', dueDate: '2026-08-15', status: 'overdue' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── WORKSHOPS (Priya / BankFirst) ───────────────────────────
|
||||||
|
|
||||||
|
export const WORKSHOPS = [
|
||||||
|
{
|
||||||
|
id: 'w1',
|
||||||
|
engagementId: 'bankfirst',
|
||||||
|
name: 'Digital Vision Alignment — Session 2',
|
||||||
|
date: '2026-08-20',
|
||||||
|
duration: 120,
|
||||||
|
facilitator: 'Priya Sharma',
|
||||||
|
stakeholders: ['CDO', 'CFO', 'CHRO', 'CTO', 'Head of Digital', 'Group Strategy Director'],
|
||||||
|
objective: 'Align executive team on 3-year digital investment priorities and resolve conflicts between cost-reduction and revenue-growth agendas.',
|
||||||
|
phase: 'Vision & Alignment',
|
||||||
|
status: 'completed',
|
||||||
|
aiSummary: 'Strong consensus emerged around customer experience as the primary digital investment thesis. Key tension: CFO prioritises cost-out (£40M target) while CDO prioritises revenue growth through new digital channels. This tension is unresolved and must be mediated before the investment committee meeting. Three decisions were made; five open questions remain.',
|
||||||
|
captures: [
|
||||||
|
{ type: 'decision', text: 'Digital investment thesis: Customer Experience as primary driver', stakeholder: 'CDO', timestamp: '09:45' },
|
||||||
|
{ type: 'decision', text: 'Core banking modernisation deferred to Year 2', stakeholder: 'CTO', timestamp: '10:20' },
|
||||||
|
{ type: 'decision', text: 'Digital talent strategy to be led by CHRO with CDO as sponsor', stakeholder: 'Group', timestamp: '11:10' },
|
||||||
|
{ type: 'tension', text: 'CFO: "£40M cost-out target conflicts with any meaningful digital investment in Year 1"', stakeholder: 'CFO', timestamp: '10:50' },
|
||||||
|
{ type: 'tension', text: 'CDO and CTO misaligned on build vs. buy for new channel platform', stakeholder: 'CDO/CTO', timestamp: '11:30' },
|
||||||
|
{ type: 'question', text: 'What is the ROI model for the digital investment? Who owns it?', stakeholder: 'CFO', timestamp: '10:55' },
|
||||||
|
{ type: 'question', text: 'How do we measure "customer experience improvement"?', stakeholder: 'CHRO', timestamp: '09:52' },
|
||||||
|
{ type: 'next-step', text: 'Priya to facilitate CFO/CDO bilateral before next group session', owner: 'Priya', due: '2026-08-27' },
|
||||||
|
{ type: 'next-step', text: 'CTO to present build vs. buy analysis for channel platform', owner: 'CTO', due: '2026-09-03' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'w2',
|
||||||
|
engagementId: 'bankfirst',
|
||||||
|
name: 'Organisational Readiness Assessment',
|
||||||
|
date: '2026-08-06',
|
||||||
|
duration: 90,
|
||||||
|
facilitator: 'Priya Sharma',
|
||||||
|
stakeholders: ['CHRO', 'Head of Digital', 'Head of IT', 'Group Strategy Director'],
|
||||||
|
objective: 'Assess current organisational capability and identify the top 3 change management risks for the transformation programme.',
|
||||||
|
phase: 'Vision & Alignment',
|
||||||
|
status: 'completed',
|
||||||
|
aiSummary: 'Readiness assessment revealed significant talent gaps in digital skills and change management capacity. Three critical risk areas identified: (1) leadership alignment deficit, (2) technology debt limiting transformation speed, (3) culture of risk-aversion in the retail banking division.',
|
||||||
|
captures: [
|
||||||
|
{ type: 'decision', text: 'Change management capability to be built internally, not outsourced', stakeholder: 'CHRO', timestamp: '09:30' },
|
||||||
|
{ type: 'question', text: 'Do we have the internal digital talent to self-sustain after the programme?', stakeholder: 'Head of IT', timestamp: '10:15' },
|
||||||
|
{ type: 'tension', text: 'CHRO believes culture change takes 3-5 years; Head of Digital expects results in 12 months', stakeholder: 'CHRO/HoD', timestamp: '10:45' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── PLAYBOOKS ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PLAYBOOKS = [
|
||||||
|
{
|
||||||
|
id: 'pb1', category: 'Agile', tag: 'SAFe',
|
||||||
|
title: 'PI Planning Facilitation Guide',
|
||||||
|
description: 'End-to-end facilitation guide for Programme Increment planning events. Covers pre-PI, day 1 briefing, team breakouts, draft plan review, and confidence vote.',
|
||||||
|
duration: '2 days', level: 'Advanced', lastUpdated: '2026-06-15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb2', category: 'Agile', tag: 'Retrospective',
|
||||||
|
title: 'Team Health Check — Spotify Model',
|
||||||
|
description: 'Structured team health survey and facilitation guide based on the Spotify Squad Health Check model, adapted for SAFe contexts.',
|
||||||
|
duration: '60 min', level: 'Foundation', lastUpdated: '2026-05-20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb3', category: 'Agile', tag: 'Impediment',
|
||||||
|
title: 'Impediment Removal Workshop',
|
||||||
|
description: 'Structured workshop to surface, categorise, and escalate team-level impediments. Includes ROAM technique for PI planning impediments.',
|
||||||
|
duration: '90 min', level: 'Intermediate', lastUpdated: '2026-07-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb4', category: 'Digital Transformation', tag: 'Readiness',
|
||||||
|
title: 'Organisational Readiness Assessment',
|
||||||
|
description: 'Diagnostic tool for assessing an organisation\'s readiness for digital transformation across 5 dimensions: Leadership, Culture, Technology, Data, and Talent.',
|
||||||
|
duration: '90 min', level: 'Intermediate', lastUpdated: '2026-08-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb5', category: 'Digital Transformation', tag: 'Alignment',
|
||||||
|
title: 'Executive Vision Alignment Workshop',
|
||||||
|
description: 'Facilitation guide for aligning C-suite stakeholders on a shared digital vision. Includes pre-work templates, facilitation scripts, and decision-capture tools.',
|
||||||
|
duration: '3 hours', level: 'Advanced', lastUpdated: '2026-07-15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb6', category: 'Facilitation', tag: 'Diverge',
|
||||||
|
title: 'Lean Coffee — Open Forum',
|
||||||
|
description: 'Agenda-less meeting format where participants self-organise discussion topics by dot-voting. Ideal for surfacing hidden impediments and building psychological safety.',
|
||||||
|
duration: '45–60 min', level: 'Foundation', lastUpdated: '2026-04-10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb7', category: 'Facilitation', tag: 'Converge',
|
||||||
|
title: 'Dot Voting — Priority Alignment',
|
||||||
|
description: 'Simple, fast technique for reaching group consensus on priorities. Works for backlog prioritisation, impediment ranking, and investment decisions.',
|
||||||
|
duration: '20–30 min', level: 'Foundation', lastUpdated: '2026-03-05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pb8', category: 'Enterprise', tag: 'Reporting',
|
||||||
|
title: 'Coaching Impact Report Template',
|
||||||
|
description: 'Structured template for reporting coaching outcomes to executive sponsors. Includes maturity trend charts, milestone summaries, and recommended next steps.',
|
||||||
|
duration: 'Template', level: 'All Levels', lastUpdated: '2026-08-10',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── PORTFOLIO SUMMARY (David) ────────────────────────────────
|
||||||
|
|
||||||
|
export const PORTFOLIO_SUMMARY = {
|
||||||
|
totalEngagements: 3,
|
||||||
|
totalTeams: 28,
|
||||||
|
totalCoaches: 4,
|
||||||
|
overallHealth: 'amber',
|
||||||
|
engagementHealth: {
|
||||||
|
meridian: { health: 'amber', teamsHealthy: 7, teamsAtRisk: 5, teamsBlocked: 2, avgMaturity: 2.7 },
|
||||||
|
bankfirst: { health: 'amber', teamsHealthy: 4, teamsAtRisk: 2, teamsBlocked: 0, avgMaturity: null },
|
||||||
|
nexus: { health: 'green', teamsHealthy: 6, teamsAtRisk: 2, teamsBlocked: 0, avgMaturity: 1.4 },
|
||||||
|
},
|
||||||
|
monthlyInsights: [
|
||||||
|
'Team Vortex and Team Nova remain blocked for 3+ weeks — systemic platform dependency needs ART-level escalation.',
|
||||||
|
'BankFirst CFO/CDO misalignment is the highest-risk item across the entire portfolio. Bilateral mediation scheduled.',
|
||||||
|
'Teams Zenith and Atlas have reached Leading maturity — consider case study documentation for Falah IP.',
|
||||||
|
'Nexus engagement is 2 weeks behind onboarding schedule due to IT infrastructure delays.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── HELPER: Today schedule for Marcus ───────────────────────
|
||||||
|
export const TODAY_SESSIONS = [
|
||||||
|
{ time: '09:30', team: 'Team Falcon', type: 'Sprint Review Coaching', duration: '90 min', teamId: 'tf' },
|
||||||
|
{ time: '11:30', team: 'Team Nova', type: '1:1 with Scrum Master', duration: '45 min', teamId: 'tn' },
|
||||||
|
{ time: '14:00', team: 'Team Vortex', type: 'Impediment Workshop', duration: '120 min', teamId: 'tv' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { BrainCircuit, Loader2, Zap, Copy, Check, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{ id: 'questions', label: 'Coaching Questions', prompt: (ctx) => `Generate 6 powerful, open-ended coaching questions for an agile coaching session. Context: ${ctx}. Make them specific, challenging, and designed to provoke reflection and action. Numbered list.` },
|
||||||
|
{ id: 'prep', label: 'Session Prep', prompt: (ctx) => `Create a structured session preparation plan for: ${ctx}. Include: 1) Session objective, 2) 3 key outcomes to achieve, 3) Suggested agenda (time-boxed), 4) Potential resistance to anticipate, 5) Opening question.` },
|
||||||
|
{ id: 'workshop', label: 'Workshop Agenda', prompt: (ctx) => `Design a 2-hour workshop agenda for: ${ctx}. Include: Opening check-in, main activities with timing, facilitation techniques, expected outputs, and closing. Be specific and practical.` },
|
||||||
|
{ id: 'summary', label: 'Progress Summary', prompt: (ctx) => `Write a concise executive progress summary for a coaching engagement. Context: ${ctx}. Include: Overall health assessment, key achievements this month, risks and blockers, and recommended next steps. Professional tone, data-driven where possible.` },
|
||||||
|
{ id: 'ceo', label: 'CEO Report Draft', prompt: (ctx) => `Draft a monthly CEO steering committee coaching progress report. Context: ${ctx}. Structure: Executive Summary (2-3 sentences), Programme Health (traffic light with rationale), Key Milestones Achieved, Risks & Mitigations, Recommendations for Executive Action. Formal, concise, outcome-focused.` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTEXT_PRESETS = [
|
||||||
|
{ label: 'Meridian Telco — SAFe Transformation, 14 teams, Scale phase', value: 'Client: Meridian Telco. Programme: SAFe 6.0 Transformation. Phase: Scale. 14 product teams across 3 BUs (Digital Products, Network Operations, Customer Experience). 6 months in. Average maturity: 2.4/4.0. Key blockers: PO empowerment gaps, platform team dependencies, tech debt accumulation.' },
|
||||||
|
{ label: 'BankFirst — Digital Transformation, Vision & Alignment', value: 'Client: BankFirst Group. Programme: Digital Transformation. Phase: Vision & Alignment. 6 C-suite stakeholders. Key tension: CFO cost-out agenda vs CDO revenue growth agenda. Core banking modernisation deferred to Year 2. Talent gap identified.' },
|
||||||
|
{ label: 'Team Falcon — Sprint Retro coaching, Developing maturity', value: 'Team: Falcon. BU: Digital Products. Maturity: Developing (2.4/4.0). Sprint: PI 4/Sprint 3. Blockers: PO lacks scope authority, 3 overdue actions, tech debt backlog invisible at ART level. 5 open actions.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function callAI(prompt) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gemini', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, context: '' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.text || '';
|
||||||
|
} catch (e) {
|
||||||
|
const GEMINI_KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
||||||
|
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: 'You are an expert agile and enterprise coaching assistant for Falah Consulting. Be concise, practical, and outcome-focused.' }] },
|
||||||
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.75, maxOutputTokens: 800 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
return d?.candidates?.[0]?.content?.parts?.[0]?.text || 'No response from AI.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIAssistant({ persona, engagement }) {
|
||||||
|
const [mode, setMode] = useState(MODES[0]);
|
||||||
|
const [context, setContext] = useState('');
|
||||||
|
const [output, setOutput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!context.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setOutput('');
|
||||||
|
const prompt = mode.prompt(context);
|
||||||
|
const text = await callAI(prompt);
|
||||||
|
setOutput(text);
|
||||||
|
setHistory(prev => [{ mode: mode.label, context: context.slice(0, 80) + (context.length > 80 ? '...' : ''), output: text, ts: new Date().toLocaleTimeString() }, ...prev.slice(0, 4)]);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(output);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-5xl mx-auto animate-fade-in space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 flex items-center gap-2.5">
|
||||||
|
<BrainCircuit size={22} className="text-navy-700" /> AI Coach Assistant
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Generate coaching questions, session plans, summaries, and reports.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-5 gap-6">
|
||||||
|
{/* Left — Input */}
|
||||||
|
<div className="md:col-span-3 space-y-4">
|
||||||
|
{/* Mode selector */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Mode</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{MODES.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-colors ${mode.id === m.id ? 'bg-navy-700 text-white border-navy-700' : 'bg-white text-gray-600 border-gray-200 hover:border-navy-300 hover:text-navy-700'}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Context</span>
|
||||||
|
<span className="text-[11px] text-gray-400">or choose a preset below</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={context}
|
||||||
|
onChange={e => setContext(e.target.value)}
|
||||||
|
placeholder="Describe the coaching situation — team, challenge, objective, what happened last session…"
|
||||||
|
className="w-full text-sm text-gray-800 placeholder-gray-300 border border-gray-200 rounded-md p-3 resize-none focus:outline-none focus:ring-1 focus:ring-navy-700 min-h-[120px] leading-relaxed"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-gray-400 uppercase tracking-wider mb-1.5">Quick Context Presets</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{CONTEXT_PRESETS.map((p, i) => (
|
||||||
|
<button key={i} onClick={() => setContext(p.value)}
|
||||||
|
className="w-full text-left text-xs text-gray-600 border border-gray-200 rounded-md px-3 py-2 hover:bg-navy-50 hover:border-navy-200 hover:text-navy-700 transition-colors">
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading || !context.trim()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-navy-700 hover:bg-navy-800 text-white text-sm font-medium py-3 rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{loading ? <><Loader2 size={15} className="animate-spin" /> Generating…</> : <><Zap size={15} /> Generate {mode.label}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — Output */}
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden min-h-[300px] flex flex-col">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{mode.label}</span>
|
||||||
|
{output && (
|
||||||
|
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-navy-700 hover:underline">
|
||||||
|
{copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Loader2 size={14} className="animate-spin text-navy-600" /> Thinking…
|
||||||
|
</div>
|
||||||
|
) : output ? (
|
||||||
|
<div className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{output}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-300 italic">Output will appear here…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Recent</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50 max-h-52 overflow-y-auto">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<button key={i} onClick={() => setOutput(h.output)}
|
||||||
|
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-gray-50 text-left transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-gray-700">{h.mode}</div>
|
||||||
|
<div className="text-[11px] text-gray-400 truncate">{h.context}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-300 shrink-0">{h.ts}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Calendar, Clock, AlertTriangle, CheckCircle, ArrowRight, Users, TrendingUp, Zap } from 'lucide-react';
|
||||||
|
import { TEAMS, SESSIONS, ACTIONS, TODAY_SESSIONS, WORKSHOPS, PORTFOLIO_SUMMARY, ENGAGEMENTS } from '../data/mock.js';
|
||||||
|
|
||||||
|
const StatusDot = ({ status }) => {
|
||||||
|
const map = { healthy: 'bg-green-500', 'at-risk': 'bg-amber-400', blocked: 'bg-red-500' };
|
||||||
|
return <span className={`inline-block w-2 h-2 rounded-full ${map[status] || 'bg-gray-300'}`} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaturityBadge = ({ level }) => {
|
||||||
|
const map = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium ${map[level] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Marcus dashboard ── */
|
||||||
|
function MarcusDashboard({ engagement, navigate }) {
|
||||||
|
const allTeams = TEAMS;
|
||||||
|
const flagged = allTeams.filter(t => t.status !== 'healthy');
|
||||||
|
const openActions = ACTIONS.filter(a => a.status === 'open' || a.status === 'overdue');
|
||||||
|
const overdueCount = ACTIONS.filter(a => a.status === 'overdue').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-5xl mx-auto animate-fade-in space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Good morning, Marcus</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-0.5">
|
||||||
|
{new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||||
|
{engagement && <span> · <span className="text-navy-700 font-medium">{engagement.client} — {engagement.name}</span></span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Teams Coaching', value: allTeams.length, icon: Users, color: 'text-navy-700' },
|
||||||
|
{ label: 'Flagged Teams', value: flagged.length, icon: AlertTriangle, color: 'text-amber-600' },
|
||||||
|
{ label: 'Open Actions', value: openActions.length, icon: CheckCircle, color: 'text-blue-600' },
|
||||||
|
{ label: 'Overdue Items', value: overdueCount, icon: Clock, color: 'text-red-600' },
|
||||||
|
].map(({ label, value, icon: Icon, color }) => (
|
||||||
|
<div key={label} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className={`${color} mb-2`}><Icon size={16} /></div>
|
||||||
|
<div className="text-2xl font-semibold text-gray-900">{value}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Today's sessions */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Calendar size={15} className="text-navy-700" /> Today's Sessions
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-gray-400">{TODAY_SESSIONS.length} scheduled</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{TODAY_SESSIONS.map((s, i) => {
|
||||||
|
const team = TEAMS.find(t => t.id === s.teamId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => navigate('team', { team })}
|
||||||
|
className="w-full flex items-start gap-4 px-5 py-3.5 hover:bg-gray-50 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-mono text-gray-400 pt-0.5 w-10 shrink-0">{s.time}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 group-hover:text-navy-700 transition-colors">{s.team}</div>
|
||||||
|
<div className="text-xs text-gray-400">{s.type} · {s.duration}</div>
|
||||||
|
</div>
|
||||||
|
{team && <StatusDot status={team.status} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flagged teams */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<AlertTriangle size={15} className="text-amber-500" /> Needs Attention
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => navigate('portfolio')} className="text-xs text-navy-700 hover:underline">View all →</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{flagged.slice(0, 5).map(team => (
|
||||||
|
<button
|
||||||
|
key={team.id}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<StatusDot status={team.status} />
|
||||||
|
<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.openActions} open actions</div>
|
||||||
|
</div>
|
||||||
|
<MaturityBadge level={team.maturityLevel} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overdue actions */}
|
||||||
|
{overdueCount > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-100 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-red-100">
|
||||||
|
<h2 className="text-sm font-semibold text-red-700 flex items-center gap-2">
|
||||||
|
<Clock size={14} /> {overdueCount} Overdue Action{overdueCount > 1 ? 's' : ''}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-red-50">
|
||||||
|
{ACTIONS.filter(a => a.status === 'overdue').map(a => {
|
||||||
|
const team = TEAMS.find(t => t.id === a.teamId);
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="flex items-start gap-4 px-5 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-gray-800">{a.text}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{team?.name} · Owner: {a.owner} · Due {a.dueDate}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-medium text-red-600 bg-red-100 px-2 py-0.5 rounded shrink-0">Overdue</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Priya dashboard ── */
|
||||||
|
function PriyaDashboard({ engagement, navigate }) {
|
||||||
|
const workshops = WORKSHOPS.filter(w => w.engagementId === engagement?.id);
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-5xl mx-auto animate-fade-in space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Good morning, Priya</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-0.5">
|
||||||
|
{new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||||
|
{engagement && <span> · <span className="text-navy-700 font-medium">{engagement.client} — {engagement.name}</span></span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Active Engagement', value: '1', icon: Zap, color: 'text-navy-700' },
|
||||||
|
{ label: 'Workshops Run', value: workshops.length, icon: Calendar, color: 'text-blue-600' },
|
||||||
|
{ label: 'Stakeholders', value: '6', icon: Users, color: 'text-emerald-600' },
|
||||||
|
{ label: 'Open Questions', value: '5', icon: AlertTriangle, color: 'text-amber-600' },
|
||||||
|
].map(({ label, value, icon: Icon, color }) => (
|
||||||
|
<div key={label} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className={`${color} mb-2`}><Icon size={16} /></div>
|
||||||
|
<div className="text-2xl font-semibold text-gray-900">{value}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Engagement phase */}
|
||||||
|
{engagement && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 font-medium uppercase tracking-wider mb-1">Current Phase</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{engagement.phase}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{engagement.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-amber-50 text-amber-700 px-2.5 py-1 rounded font-medium">{engagement.phase}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent workshop */}
|
||||||
|
{workshops[0] && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Latest Workshop</h2>
|
||||||
|
<button onClick={() => navigate('workshops')} className="text-xs text-navy-700 hover:underline">View all →</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold text-gray-900">{workshops[0].name}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{workshops[0].date} · {workshops[0].duration} min · {workshops[0].stakeholders.length} stakeholders</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded font-medium">Completed</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed line-clamp-3">{workshops[0].aiSummary}</p>
|
||||||
|
<div className="mt-4 flex gap-4 text-xs text-gray-500">
|
||||||
|
<span className="text-green-700">✓ {workshops[0].captures.filter(c=>c.type==='decision').length} decisions</span>
|
||||||
|
<span className="text-amber-600">⚡ {workshops[0].captures.filter(c=>c.type==='tension').length} tensions</span>
|
||||||
|
<span className="text-blue-600">? {workshops[0].captures.filter(c=>c.type==='question').length} open questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── David dashboard ── */
|
||||||
|
function DavidDashboard({ navigate }) {
|
||||||
|
const ps = PORTFOLIO_SUMMARY;
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-5xl mx-auto animate-fade-in space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Good morning, David</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-0.5">
|
||||||
|
{new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })} · Portfolio View
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Engagements', value: ps.totalEngagements, icon: Zap, color: 'text-navy-700' },
|
||||||
|
{ label: 'Teams Coached', value: ps.totalTeams, icon: Users, color: 'text-blue-600' },
|
||||||
|
{ label: 'Active Coaches', value: ps.totalCoaches, icon: TrendingUp, color: 'text-emerald-600' },
|
||||||
|
{ label: 'Portfolio Health', value: ps.overallHealth.toUpperCase(), icon: AlertTriangle, color: ps.overallHealth === 'green' ? 'text-green-600' : 'text-amber-600' },
|
||||||
|
].map(({ label, value, icon: Icon, color }) => (
|
||||||
|
<div key={label} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className={`${color} mb-2`}><Icon size={16} /></div>
|
||||||
|
<div className="text-2xl font-semibold text-gray-900">{value}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Monthly Insights</h2>
|
||||||
|
<button onClick={() => navigate('reports')} className="text-xs text-navy-700 hover:underline">Generate CEO Report →</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{ps.monthlyInsights.map((insight, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 px-5 py-3.5">
|
||||||
|
<span className="w-5 h-5 bg-navy-50 text-navy-700 rounded text-xs font-semibold flex items-center justify-center shrink-0 mt-0.5">{i+1}</span>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{insight}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Engagement Health</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{ENGAGEMENTS.map(e => {
|
||||||
|
const h = ps.engagementHealth[e.id];
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="flex items-center gap-4 px-5 py-4">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${h.health==='green' ? 'bg-green-500' : h.health==='amber' ? 'bg-amber-400' : 'bg-red-500'}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{e.client} — {e.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{e.phase} · {e.industry}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-gray-500 shrink-0">
|
||||||
|
<div><span className="text-green-600 font-medium">{h.teamsHealthy}</span> healthy</div>
|
||||||
|
<div><span className="text-amber-600 font-medium">{h.teamsAtRisk}</span> at risk</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Router ── */
|
||||||
|
export default function Dashboard({ persona, engagement, navigate }) {
|
||||||
|
if (persona.id === 'priya') return <PriyaDashboard engagement={engagement} navigate={navigate} />;
|
||||||
|
if (persona.id === 'david') return <DavidDashboard navigate={navigate} />;
|
||||||
|
return <MarcusDashboard engagement={engagement} navigate={navigate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Search, BookOpen, Clock, ChevronRight } from 'lucide-react';
|
||||||
|
import { PLAYBOOKS } from '../data/mock.js';
|
||||||
|
|
||||||
|
const CATEGORIES = ['All', 'Agile', 'Digital Transformation', 'Facilitation', 'Enterprise'];
|
||||||
|
const LEVELS = ['All', 'Foundation', 'Intermediate', 'Advanced'];
|
||||||
|
|
||||||
|
const LEVEL_STYLE = {
|
||||||
|
Foundation: 'bg-gray-100 text-gray-600',
|
||||||
|
Intermediate: 'bg-blue-50 text-blue-700',
|
||||||
|
Advanced: 'bg-navy-50 text-navy-700',
|
||||||
|
'All Levels': 'bg-emerald-50 text-emerald-700',
|
||||||
|
Template: 'bg-purple-50 text-purple-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAT_STYLE = {
|
||||||
|
Agile: 'bg-blue-50 text-blue-700',
|
||||||
|
'Digital Transformation': 'bg-emerald-50 text-emerald-700',
|
||||||
|
Facilitation: 'bg-amber-50 text-amber-700',
|
||||||
|
Enterprise: 'bg-navy-50 text-navy-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
function PlaybookCard({ pb }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden hover:border-navy-200 transition-colors">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-start gap-4 p-5 text-left group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-navy-50 rounded-lg flex items-center justify-center shrink-0">
|
||||||
|
<BookOpen size={18} className="text-navy-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 group-hover:text-navy-700 transition-colors">{pb.title}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`text-[11px] font-medium px-2 py-0.5 rounded ${CAT_STYLE[pb.category] || 'bg-gray-100 text-gray-600'}`}>{pb.tag}</span>
|
||||||
|
<span className={`text-[11px] font-medium px-2 py-0.5 rounded ${LEVEL_STYLE[pb.level] || 'bg-gray-100 text-gray-600'}`}>{pb.level}</span>
|
||||||
|
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock size={10} /> {pb.duration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={15} className={`text-gray-300 shrink-0 mt-0.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-5 pb-5 border-t border-gray-100 bg-gray-50/30 animate-fade-in">
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed mt-4">{pb.description}</p>
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span className="text-xs text-gray-400">Last updated {pb.lastUpdated}</span>
|
||||||
|
<button className="text-xs font-medium text-navy-700 hover:underline">Open Playbook →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Playbooks() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState('All');
|
||||||
|
const [level, setLevel] = useState('All');
|
||||||
|
|
||||||
|
let books = PLAYBOOKS;
|
||||||
|
if (search) books = books.filter(p => p.title.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()) || p.tag.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
if (category !== 'All') books = books.filter(p => p.category === category);
|
||||||
|
if (level !== 'All') books = books.filter(p => p.level === level || p.level === 'All Levels');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-4xl mx-auto animate-fade-in space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Playbook Library</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Agile frameworks, DT models, and facilitation exercises.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<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 playbooks…"
|
||||||
|
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>
|
||||||
|
<select value={category} onChange={e => setCategory(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">
|
||||||
|
{CATEGORIES.map(c => <option key={c}>{c === 'All' ? 'Category: All' : c}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={level} onChange={e => setLevel(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">
|
||||||
|
{LEVELS.map(l => <option key={l}>{l === 'All' ? 'Level: All' : l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{CATEGORIES.map(c => (
|
||||||
|
<button key={c} onClick={() => setCategory(c)}
|
||||||
|
className={`text-xs font-medium px-3 py-1.5 rounded-full border transition-colors ${category === c ? 'bg-navy-700 text-white border-navy-700' : 'bg-white text-gray-600 border-gray-200 hover:border-navy-200'}`}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{books.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-sm text-gray-400">No playbooks match your filters.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{books.map(pb => <PlaybookCard key={pb.id} pb={pb} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 text-center">Showing {books.length} of {PLAYBOOKS.length} playbooks</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { BarChart2, TrendingUp, AlertTriangle, Users, Zap, Loader2, Copy, Check } from 'lucide-react';
|
||||||
|
import { ENGAGEMENTS, PORTFOLIO_SUMMARY, TEAMS } from '../data/mock.js';
|
||||||
|
|
||||||
|
const HEALTH_STYLE = {
|
||||||
|
green: { dot: 'bg-green-500', badge: 'bg-green-50 text-green-700', label: 'On Track' },
|
||||||
|
amber: { dot: 'bg-amber-400', badge: 'bg-amber-50 text-amber-700', label: 'At Risk' },
|
||||||
|
red: { dot: 'bg-red-500', badge: 'bg-red-50 text-red-600', label: 'Critical' },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function callAI(prompt) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gemini', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, context: '' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.text || '';
|
||||||
|
} catch (e) {
|
||||||
|
const KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
||||||
|
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: 'You are a senior enterprise coaching expert at Falah Consulting. Write formal, data-driven reports for C-suite audiences.' }] },
|
||||||
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.6, maxOutputTokens: 1200 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
return d?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini bar for maturity distribution */
|
||||||
|
function MaturityBar({ label, count, total, color }) {
|
||||||
|
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-xs text-gray-500 w-20 shrink-0">{label}</div>
|
||||||
|
<div className="flex-1 bg-gray-100 rounded-full h-1.5">
|
||||||
|
<div className={`h-1.5 rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-gray-500 w-6 text-right">{count}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Reports({ persona }) {
|
||||||
|
const ps = PORTFOLIO_SUMMARY;
|
||||||
|
const [reportLoading, setReportLoading] = useState(false);
|
||||||
|
const [report, setReport] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Maturity distribution for Meridian
|
||||||
|
const maturityDist = {
|
||||||
|
Leading: TEAMS.filter(t => t.maturityLevel === 'Leading').length,
|
||||||
|
Performing: TEAMS.filter(t => t.maturityLevel === 'Performing').length,
|
||||||
|
Developing: TEAMS.filter(t => t.maturityLevel === 'Developing').length,
|
||||||
|
Foundation: TEAMS.filter(t => t.maturityLevel === 'Foundation').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateReport = async () => {
|
||||||
|
setReportLoading(true);
|
||||||
|
const context = `Portfolio: 3 active engagements — Meridian Telco SAFe Transformation (14 teams, Scale phase, avg maturity 2.7/4.0, 2 blocked teams), BankFirst Digital Transformation (Vision phase, CFO/CDO tension unresolved), Nexus Energy Agile at Scale (Foundation phase, on track). Overall portfolio health: Amber. Key risks: Meridian platform dependency blocking 2 teams, BankFirst executive alignment gap, Nexus 2-week schedule slippage. Wins: Teams Zenith and Atlas reached Leading maturity. 4 coaches deployed.`;
|
||||||
|
const prompt = `Draft a monthly CEO steering committee coaching progress report for Falah Consulting.\n\nContext: ${context}\n\nStructure:\n1. EXECUTIVE SUMMARY (2-3 sentences)\n2. PORTFOLIO HEALTH OVERVIEW (traffic light per engagement with 1-line rationale)\n3. KEY ACHIEVEMENTS THIS MONTH (3-4 bullet points, specific and measurable)\n4. RISKS & MITIGATIONS (top 3, each with: Risk / Impact / Mitigation action)\n5. RECOMMENDATIONS FOR EXECUTIVE ACTION (2-3 actionable recommendations for the steering committee)\n\nTone: Formal, concise, outcome-focused. Suitable for a Group CEO audience.`;
|
||||||
|
const text = await callAI(prompt);
|
||||||
|
setReport(text);
|
||||||
|
setReportLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(report);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-5xl mx-auto animate-fade-in space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Portfolio Overview</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Cross-engagement health · David Walsh · Partner</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Engagements', value: ps.totalEngagements, icon: Zap },
|
||||||
|
{ label: 'Teams', value: ps.totalTeams, icon: Users },
|
||||||
|
{ label: 'Coaches', value: ps.totalCoaches, icon: TrendingUp },
|
||||||
|
{ label: 'Portfolio Health', value: ps.overallHealth === 'green' ? 'ON TRACK' : ps.overallHealth === 'amber' ? 'AT RISK' : 'CRITICAL', icon: BarChart2 },
|
||||||
|
].map(({ label, value, icon: Icon }) => (
|
||||||
|
<div key={label} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="text-navy-700 mb-2"><Icon size={16} /></div>
|
||||||
|
<div className="text-2xl font-semibold text-gray-900">{value}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Engagement health */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Engagement Health</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{ENGAGEMENTS.map(e => {
|
||||||
|
const h = ps.engagementHealth[e.id];
|
||||||
|
const hs = HEALTH_STYLE[h.health] || HEALTH_STYLE.amber;
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="px-5 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{e.client}</div>
|
||||||
|
<div className="text-xs text-gray-400">{e.name} · {e.phase}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[11px] font-medium px-2 py-0.5 rounded shrink-0 ${hs.badge}`}>{hs.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-xs text-gray-500">
|
||||||
|
<span className="text-green-600 font-medium">{h.teamsHealthy} healthy</span>
|
||||||
|
{h.teamsAtRisk > 0 && <span className="text-amber-600 font-medium">{h.teamsAtRisk} at risk</span>}
|
||||||
|
{h.teamsBlocked > 0 && <span className="text-red-600 font-medium">{h.teamsBlocked} blocked</span>}
|
||||||
|
{h.avgMaturity && <span className="text-gray-400">Avg maturity: {h.avgMaturity}/4.0</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meridian maturity distribution */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Meridian — Maturity Distribution</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">14 teams across 3 BUs</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-5 space-y-3">
|
||||||
|
<MaturityBar label="Leading" count={maturityDist.Leading} total={14} color="bg-navy-700" />
|
||||||
|
<MaturityBar label="Performing" count={maturityDist.Performing} total={14} color="bg-emerald-500" />
|
||||||
|
<MaturityBar label="Developing" count={maturityDist.Developing} total={14} color="bg-blue-500" />
|
||||||
|
<MaturityBar label="Foundation" count={maturityDist.Foundation} total={14} color="bg-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
<div className="bg-amber-50 border border-amber-100 rounded-md p-3 text-xs text-amber-700">
|
||||||
|
<span className="font-semibold">Systemic pattern:</span> All 3 blocked/Foundation teams share the same platform dependency blocker. Recommend ART-level escalation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly insights */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Portfolio Insights</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{ps.monthlyInsights.map((insight, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 px-5 py-3.5">
|
||||||
|
<span className="w-5 h-5 bg-navy-50 text-navy-700 rounded text-xs font-semibold flex items-center justify-center shrink-0 mt-0.5">{i+1}</span>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{insight}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CEO report generator */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Zap size={14} className="text-navy-600" /> CEO Steering Committee Report
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">AI-generated from portfolio data — ready to edit and distribute</p>
|
||||||
|
</div>
|
||||||
|
{report && (
|
||||||
|
<button onClick={handleCopy} className="flex items-center gap-1.5 text-xs font-medium text-navy-700 border border-navy-200 bg-navy-50 hover:bg-navy-100 px-3 py-1.5 rounded-md transition-colors">
|
||||||
|
{copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy Report</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{report ? (
|
||||||
|
<div className="text-sm text-gray-700 leading-relaxed whitespace-pre-line mb-4">{report}</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 mb-4">Generate a monthly progress report for the Group CEO — structured narrative covering portfolio health, achievements, risks, and executive recommendations.</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={generateReport}
|
||||||
|
disabled={reportLoading}
|
||||||
|
className="flex items-center justify-center gap-2 bg-navy-700 hover:bg-navy-800 text-white text-sm font-medium px-5 py-2.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reportLoading ? <><Loader2 size={14} className="animate-spin" /> Drafting report…</> : <><Zap size={14} /> {report ? 'Regenerate Report' : 'Draft CEO Report'}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { ArrowLeft, Zap, Loader2, Save, Tag, AlertCircle, HelpCircle, CheckCircle, TrendingDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const TAG_TYPES = [
|
||||||
|
{ type: 'blocker', label: '⛔ Blocker', color: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100' },
|
||||||
|
{ type: 'action', label: '✓ Action', color: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100' },
|
||||||
|
{ type: 'decision', label: '● Decision', color: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100' },
|
||||||
|
{ type: 'question', label: '? Question', color: 'bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100' },
|
||||||
|
{ type: 'tension', label: '⚡ Tension', color: 'bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SESSION_TYPES = [
|
||||||
|
'Sprint Retrospective Coaching',
|
||||||
|
'Sprint Review Coaching',
|
||||||
|
'Team Health Check',
|
||||||
|
'Impediment Removal Workshop',
|
||||||
|
'1:1 with Scrum Master',
|
||||||
|
'1:1 with Product Owner',
|
||||||
|
'PI Planning Coaching',
|
||||||
|
'ART Sync Coaching',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function callAI(prompt, context) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gemini', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, context }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.text || '';
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct Gemini call if serverless not available
|
||||||
|
const GEMINI_KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
||||||
|
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: 'You are an expert agile coaching assistant for Falah Consulting. Respond concisely and practically.' }] },
|
||||||
|
contents: [{ role: 'user', parts: [{ text: `Context: ${context}\n\n${prompt}` }] }],
|
||||||
|
generationConfig: { temperature: 0.7, maxOutputTokens: 512 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
return d?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionCapture({ team, navigate }) {
|
||||||
|
const [sessionType, setSessionType] = useState(SESSION_TYPES[0]);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [tagged, setTagged] = useState([]);
|
||||||
|
const [activeTag, setActiveTag] = useState(null);
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
const [aiSummary, setAiSummary] = useState('');
|
||||||
|
const [aiQuestions, setAiQuestions] = useState('');
|
||||||
|
const [qLoading, setQLoading] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const textRef = useRef(null);
|
||||||
|
|
||||||
|
const teamName = team?.name || 'Unknown Team';
|
||||||
|
const teamContext = team ? `Team: ${team.name}, BU: ${team.bu}, Maturity: ${team.maturityLevel} (${team.maturityScore}/4), Sprint: ${team.sprint}, Open actions: ${team.openActions}` : '';
|
||||||
|
|
||||||
|
// Tag a selected note line
|
||||||
|
const tagSelection = useCallback(() => {
|
||||||
|
if (!activeTag || !textRef.current) return;
|
||||||
|
const start = textRef.current.selectionStart;
|
||||||
|
const end = textRef.current.selectionEnd;
|
||||||
|
const selected = notes.slice(start, end).trim();
|
||||||
|
if (!selected) return;
|
||||||
|
setTagged(prev => [...prev, { type: activeTag, text: selected, id: Date.now() }]);
|
||||||
|
}, [activeTag, notes]);
|
||||||
|
|
||||||
|
const removeTag = (id) => setTagged(prev => prev.filter(t => t.id !== id));
|
||||||
|
|
||||||
|
const generateQuestions = async () => {
|
||||||
|
setQLoading(true);
|
||||||
|
const prompt = `Generate 5 targeted coaching questions for a ${sessionType} session with ${teamName}. Each question should be open-ended, specific to their situation, and push the team to reflect and act. Format as a numbered list.`;
|
||||||
|
const text = await callAI(prompt, teamContext);
|
||||||
|
setAiQuestions(text);
|
||||||
|
setQLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateSummary = async () => {
|
||||||
|
setAiLoading(true);
|
||||||
|
const tagStr = tagged.map(t => `[${t.type.toUpperCase()}] ${t.text}`).join('\n');
|
||||||
|
const prompt = `Write a concise coaching session summary (3–5 sentences) based on these session notes and captured items. End with a "Recommended Next Intervention" sentence.\n\nSession Notes:\n${notes}\n\nCaptured Items:\n${tagStr}`;
|
||||||
|
const text = await callAI(prompt, teamContext);
|
||||||
|
setAiSummary(text);
|
||||||
|
setAiLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => navigate('team', { team }), 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => navigate('team', { team })} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-navy-700 transition-colors">
|
||||||
|
<ArrowLeft size={15} /> {teamName}
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={14} className="text-gray-300" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">New Session</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-400 hidden sm:block">
|
||||||
|
{new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saved}
|
||||||
|
className={`flex items-center gap-1.5 text-sm font-medium px-4 py-2 rounded-md transition-colors ${saved ? 'bg-green-50 text-green-700' : 'bg-navy-700 hover:bg-navy-800 text-white'}`}
|
||||||
|
>
|
||||||
|
{saved ? <><CheckCircle size={14} /> Saved</> : <><Save size={14} /> Save Session</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-6 grid md:grid-cols-5 gap-6">
|
||||||
|
|
||||||
|
{/* Left — Notes + Tags */}
|
||||||
|
<div className="md:col-span-3 space-y-5">
|
||||||
|
{/* Session type */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Session Type</label>
|
||||||
|
<select value={sessionType} onChange={e => setSessionType(e.target.value)}
|
||||||
|
className="w-full text-sm border border-gray-200 rounded-md px-3 py-2 bg-white text-gray-800 focus:outline-none focus:ring-1 focus:ring-navy-700">
|
||||||
|
{SESSION_TYPES.map(t => <option key={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Session Notes</span>
|
||||||
|
<span className="text-[11px] text-gray-400">Select text → choose tag type to capture</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textRef}
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder={`Type your coaching notes here…\n\nTip: Select any text, then click a tag button below to capture it as a Decision, Action, Blocker, etc.`}
|
||||||
|
className="w-full p-4 text-sm text-gray-800 placeholder-gray-300 resize-none focus:outline-none min-h-[280px] font-mono leading-relaxed"
|
||||||
|
/>
|
||||||
|
{/* Tag toolbar */}
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
|
||||||
|
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-wider">Tag Selected Text</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TAG_TYPES.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.type}
|
||||||
|
onClick={() => { setActiveTag(t.type); tagSelection(); }}
|
||||||
|
className={`text-xs font-medium px-2.5 py-1 rounded border transition-colors ${t.color} ${activeTag === t.type ? 'ring-2 ring-offset-1 ring-navy-400' : ''}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagged items */}
|
||||||
|
{tagged.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Captured Items ({tagged.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50 max-h-64 overflow-y-auto">
|
||||||
|
{tagged.map(item => {
|
||||||
|
const t = TAG_TYPES.find(x => x.type === item.type);
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded border shrink-0 ${t?.color}`}>{t?.label}</span>
|
||||||
|
<span className="text-sm text-gray-700 flex-1 min-w-0 truncate">{item.text}</span>
|
||||||
|
<button onClick={() => removeTag(item.id)} className="text-gray-300 hover:text-red-400 shrink-0 text-xs">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — AI Panel */}
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
{/* Coaching questions */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<HelpCircle size={12} className="text-navy-600" /> AI Coaching Questions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{aiQuestions ? (
|
||||||
|
<div className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{aiQuestions}</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">Get AI-generated coaching questions tailored to {teamName}'s current situation.</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={generateQuestions}
|
||||||
|
disabled={qLoading}
|
||||||
|
className="mt-3 w-full flex items-center justify-center gap-2 text-xs font-medium text-navy-700 border border-navy-200 bg-navy-50 hover:bg-navy-100 px-3 py-2 rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{qLoading ? <><Loader2 size={12} className="animate-spin" /> Generating…</> : <><Zap size={12} /> Generate Questions</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Summary */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<Zap size={12} className="text-navy-600" /> AI Session Summary
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{aiSummary ? (
|
||||||
|
<div className="text-sm text-gray-700 leading-relaxed">{aiSummary}</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">Generate a structured summary from your notes and tagged items after the session.</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={generateSummary}
|
||||||
|
disabled={aiLoading || (!notes && tagged.length === 0)}
|
||||||
|
className="mt-3 w-full flex items-center justify-center gap-2 text-xs font-medium text-white bg-navy-700 hover:bg-navy-800 px-3 py-2 rounded-md transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{aiLoading ? <><Loader2 size={12} className="animate-spin" /> Summarising…</> : <><Zap size={12} /> Generate Summary</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context card */}
|
||||||
|
<div className="bg-navy-50 border border-navy-100 rounded-lg p-4">
|
||||||
|
<div className="text-[10px] font-semibold text-navy-600 uppercase tracking-wider mb-2">Team Context</div>
|
||||||
|
<div className="space-y-1 text-xs text-navy-700">
|
||||||
|
<div><span className="text-navy-500">Team:</span> {team?.name}</div>
|
||||||
|
<div><span className="text-navy-500">BU:</span> {team?.bu}</div>
|
||||||
|
<div><span className="text-navy-500">Maturity:</span> {team?.maturityLevel} ({team?.maturityScore}/4)</div>
|
||||||
|
<div><span className="text-navy-500">Open Actions:</span> {team?.openActions}</div>
|
||||||
|
<div><span className="text-navy-500">Last Session:</span> {team?.lastSession}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ArrowLeft, Play, CheckCircle2, Clock, AlertTriangle, ChevronDown, ChevronUp, Calendar, Users, Zap } from 'lucide-react';
|
||||||
|
import { SESSIONS, ACTIONS } from '../data/mock.js';
|
||||||
|
|
||||||
|
const TAG_STYLE = {
|
||||||
|
blocker: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-100', label: 'Blocker', dot: 'bg-red-500' },
|
||||||
|
action: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-100', label: 'Action', dot: 'bg-blue-500' },
|
||||||
|
decision: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-100', label: 'Decision', dot: 'bg-green-500' },
|
||||||
|
question: { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-100', label: 'Question', dot: 'bg-amber-400' },
|
||||||
|
tension: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-100', label: 'Tension', dot: 'bg-purple-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MATURITY_COLOR = {
|
||||||
|
Foundation: { bar: 'bg-gray-400', text: 'text-gray-600', bg: 'bg-gray-100' },
|
||||||
|
Developing: { bar: 'bg-blue-500', text: 'text-blue-700', bg: 'bg-blue-50' },
|
||||||
|
Performing: { bar: 'bg-emerald-500', text: 'text-emerald-700', bg: 'bg-emerald-50' },
|
||||||
|
Leading: { bar: 'bg-navy-700', text: 'text-navy-700', bg: 'bg-navy-50' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function MaturityBar({ score, level }) {
|
||||||
|
const c = MATURITY_COLOR[level] || MATURITY_COLOR.Foundation;
|
||||||
|
const pct = (score / 4) * 100;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className={`text-xs font-medium ${c.text}`}>{level}</span>
|
||||||
|
<span className="text-xs font-mono text-gray-400">{score.toFixed(1)} / 4.0</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-100 rounded-full h-1.5">
|
||||||
|
<div className={`h-1.5 rounded-full ${c.bar} transition-all`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCard({ session, navigate, team }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const tagCounts = session.tags?.reduce((acc, t) => { acc[t.type] = (acc[t.type]||0)+1; return acc; }, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
className="w-full flex items-center gap-4 px-5 py-4 hover:bg-gray-50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-navy-50 rounded-lg flex items-center justify-center shrink-0">
|
||||||
|
<Calendar size={15} className="text-navy-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{session.type}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{session.date} · {session.coach} · {session.duration} min</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-2 text-xs">
|
||||||
|
{tagCounts?.blocker && <span className="text-red-600 bg-red-50 px-2 py-0.5 rounded">{tagCounts.blocker} blocker{tagCounts.blocker>1?'s':''}</span>}
|
||||||
|
{tagCounts?.action && <span className="text-blue-600 bg-blue-50 px-2 py-0.5 rounded">{tagCounts.action} action{tagCounts.action>1?'s':''}</span>}
|
||||||
|
{tagCounts?.decision && <span className="text-green-600 bg-green-50 px-2 py-0.5 rounded">{tagCounts.decision} decision{tagCounts.decision>1?'s':''}</span>}
|
||||||
|
</div>
|
||||||
|
{expanded ? <ChevronUp size={15} className="text-gray-400 shrink-0" /> : <ChevronDown size={15} className="text-gray-400 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-5 pb-5 border-t border-gray-100 bg-gray-50/50 animate-fade-in">
|
||||||
|
{/* AI Summary */}
|
||||||
|
{session.aiSummary && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
|
<Zap size={10} className="text-navy-600" /> AI Summary
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{session.aiSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Tags */}
|
||||||
|
{session.tags?.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Captured Items</div>
|
||||||
|
{session.tags.map((tag, i) => {
|
||||||
|
const s = TAG_STYLE[tag.type] || TAG_STYLE.action;
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex items-start gap-2.5 p-3 rounded-md border ${s.bg} ${s.border}`}>
|
||||||
|
<span className={`inline-block w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${s.dot}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={`text-[10px] font-semibold uppercase tracking-wider ${s.text} mr-2`}>{s.label}</span>
|
||||||
|
<span className="text-sm text-gray-800">{tag.text}</span>
|
||||||
|
{tag.owner && <div className="text-xs text-gray-400 mt-0.5">Owner: {tag.owner} · Due: {tag.due}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamDetail({ team, navigate }) {
|
||||||
|
if (!team) return (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
<p>No team selected.</p>
|
||||||
|
<button onClick={() => navigate('portfolio')} className="mt-4 text-navy-700 text-sm hover:underline">← Back to Portfolio</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessions = SESSIONS.filter(s => s.teamId === team.id);
|
||||||
|
const actions = ACTIONS.filter(a => a.teamId === team.id);
|
||||||
|
const open = actions.filter(a => a.status === 'open');
|
||||||
|
const overdue = actions.filter(a => a.status === 'overdue');
|
||||||
|
const done = actions.filter(a => a.status === 'done');
|
||||||
|
|
||||||
|
const mc = MATURITY_COLOR[team.maturityLevel] || MATURITY_COLOR.Foundation;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-4xl mx-auto animate-fade-in space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<button onClick={() => navigate('portfolio')} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-navy-700 transition-colors">
|
||||||
|
<ArrowLeft size={15} /> Back to Portfolio
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Team header */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">{team.name}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">{team.bu} · {team.members} members · {team.sprint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<span className={`text-xs font-medium px-2.5 py-1.5 rounded-md ${
|
||||||
|
team.status === 'healthy' ? 'bg-green-50 text-green-700' :
|
||||||
|
team.status === 'at-risk' ? 'bg-amber-50 text-amber-700' :
|
||||||
|
'bg-red-50 text-red-600'
|
||||||
|
}`}>
|
||||||
|
{team.status === 'at-risk' ? 'At Risk' : team.status.charAt(0).toUpperCase() + team.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('session', { team, session: null })}
|
||||||
|
className="flex items-center gap-1.5 bg-navy-700 hover:bg-navy-800 text-white text-xs font-medium px-3 py-1.5 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Play size={12} fill="white" /> Start Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maturity */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-2">Agile Maturity</div>
|
||||||
|
<MaturityBar score={team.maturityScore} level={team.maturityLevel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maturity history sparkline */}
|
||||||
|
{team.maturityHistory?.length > 1 && (
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-3">Maturity Trend</div>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
{team.maturityHistory.map((h, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-1">
|
||||||
|
<div className="text-[10px] font-mono text-gray-600">{h.score.toFixed(1)}</div>
|
||||||
|
<div
|
||||||
|
className={`w-8 rounded-sm ${mc.bar}`}
|
||||||
|
style={{ height: `${Math.max((h.score / 4) * 48, 4)}px` }}
|
||||||
|
/>
|
||||||
|
<div className="text-[9px] text-gray-400">{h.month}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="ml-2 text-xs text-gray-400 self-center">
|
||||||
|
<span className={`font-medium ${mc.text}`}>+{(team.maturityScore - team.maturityHistory[0].score).toFixed(1)}</span> from start
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<CheckCircle2 size={15} className="text-navy-700" /> Action Tracker
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
{overdue.length > 0 && <span className="bg-red-50 text-red-600 px-2 py-0.5 rounded font-medium">{overdue.length} overdue</span>}
|
||||||
|
{open.length > 0 && <span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded">{open.length} open</span>}
|
||||||
|
{done.length > 0 && <span className="bg-green-50 text-green-600 px-2 py-0.5 rounded">{done.length} done</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actions.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center text-sm text-gray-400">No actions recorded for this team.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{actions.map(a => (
|
||||||
|
<div key={a.id} className="flex items-start gap-4 px-5 py-3.5">
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center ${
|
||||||
|
a.status === 'done' ? 'border-green-500 bg-green-500' :
|
||||||
|
a.status === 'overdue' ? 'border-red-500' : 'border-blue-400'
|
||||||
|
}`}>
|
||||||
|
{a.status === 'done' && <CheckCircle2 size={10} className="text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-sm ${a.status === 'done' ? 'text-gray-400 line-through' : 'text-gray-800'}`}>{a.text}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">Owner: {a.owner} · Due {a.dueDate}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[11px] font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
|
a.status === 'done' ? 'bg-green-50 text-green-700' :
|
||||||
|
a.status === 'overdue' ? 'bg-red-50 text-red-600' :
|
||||||
|
'bg-blue-50 text-blue-700'
|
||||||
|
}`}>
|
||||||
|
{a.status.charAt(0).toUpperCase() + a.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session log */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Session History</h2>
|
||||||
|
<span className="text-xs text-gray-400">{sessions.length} session{sessions.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg px-5 py-8 text-center text-sm text-gray-400">
|
||||||
|
No sessions recorded yet.
|
||||||
|
<br />
|
||||||
|
<button onClick={() => navigate('session', { team, session: null })}
|
||||||
|
className="mt-3 text-navy-700 text-sm hover:underline">Start the first session →</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map(s => <SessionCard key={s.id} session={s} navigate={navigate} team={team} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ArrowLeft, CheckCircle, AlertTriangle, HelpCircle, ArrowRight, Zap, Loader2, Share2, Copy, Check, ChevronRight } from 'lucide-react';
|
||||||
|
import { WORKSHOPS } from '../data/mock.js';
|
||||||
|
|
||||||
|
const CAPTURE_TYPES = [
|
||||||
|
{ type: 'decision', label: '✓ Decision', color: 'bg-green-50 border-green-200 text-green-700 hover:bg-green-100' },
|
||||||
|
{ type: 'tension', label: '⚡ Tension', color: 'bg-red-50 border-red-200 text-red-700 hover:bg-red-100' },
|
||||||
|
{ type: 'question', label: '? Question', color: 'bg-amber-50 border-amber-200 text-amber-700 hover:bg-amber-100' },
|
||||||
|
{ type: 'next-step', label: '→ Next Step', color: 'bg-blue-50 border-blue-200 text-blue-700 hover:bg-blue-100' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STYLE = {
|
||||||
|
decision: { bg: 'bg-green-50', border: 'border-green-100', text: 'text-green-700', dot: 'bg-green-500', label: 'Decision' },
|
||||||
|
tension: { bg: 'bg-red-50', border: 'border-red-100', text: 'text-red-700', dot: 'bg-red-500', label: 'Tension' },
|
||||||
|
question: { bg: 'bg-amber-50', border: 'border-amber-100', text: 'text-amber-700', dot: 'bg-amber-400', label: 'Question' },
|
||||||
|
'next-step':{ bg: 'bg-blue-50', border: 'border-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', label: 'Next Step' },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function callAI(prompt) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gemini', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, context: '' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.text || '';
|
||||||
|
} catch (e) {
|
||||||
|
const KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
||||||
|
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: 'You are an expert executive workshop facilitator. Be concise, structured, and practical.' }] },
|
||||||
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.7, maxOutputTokens: 800 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
return d?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkshopCapture({ workshop: initialWorkshop, navigate }) {
|
||||||
|
const workshop = initialWorkshop || WORKSHOPS[0]; // default to latest
|
||||||
|
const [liveNote, setLiveNote] = useState('');
|
||||||
|
const [activeType, setActiveType] = useState('decision');
|
||||||
|
const [captures, setCaptures] = useState(workshop?.captures || []);
|
||||||
|
const [aiInput, setAiInput] = useState('');
|
||||||
|
const [aiResponse, setAiResponse] = useState('');
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
|
const [summary, setSummary] = useState(workshop?.aiSummary || '');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [view, setView] = useState(initialWorkshop ? 'summary' : 'live'); // 'live' | 'summary'
|
||||||
|
|
||||||
|
const addCapture = () => {
|
||||||
|
if (!liveNote.trim()) return;
|
||||||
|
setCaptures(prev => [...prev, { type: activeType, text: liveNote.trim(), timestamp: new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) }]);
|
||||||
|
setLiveNote('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCapture = (idx) => setCaptures(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
|
const handleAiAssist = async () => {
|
||||||
|
if (!aiInput.trim()) return;
|
||||||
|
setAiLoading(true);
|
||||||
|
const prompt = `You are a workshop facilitator. Context: ${workshop?.objective || 'executive alignment workshop'}.\n\nQuestion from facilitator: ${aiInput}\n\nProvide a brief, practical facilitation move or technique (2-3 sentences max).`;
|
||||||
|
const text = await callAI(prompt);
|
||||||
|
setAiResponse(text);
|
||||||
|
setAiLoading(false);
|
||||||
|
setAiInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateSummary = async () => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
const captureStr = captures.map(c => `[${c.type.toUpperCase()}] ${c.text}`).join('\n');
|
||||||
|
const prompt = `Write a structured workshop summary from these captured items:\n\n${captureStr}\n\nWorkshop objective: ${workshop?.objective || 'Executive alignment'}.\n\nFormat as:\nDECISIONS MADE:\n- ...\n\nOPEN QUESTIONS:\n- ...\n\nTENSIONS TO RESOLVE:\n- ...\n\nNEXT STEPS:\n- ...\n\nOVERALL ASSESSMENT: (1-2 sentences on alignment level and key risk)`;
|
||||||
|
const text = await callAI(prompt);
|
||||||
|
setSummary(text);
|
||||||
|
setSummaryLoading(false);
|
||||||
|
setView('summary');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(summary);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedCaptures = CAPTURE_TYPES.map(t => ({
|
||||||
|
...t,
|
||||||
|
items: captures.filter(c => c.type === t.type),
|
||||||
|
})).filter(g => g.items.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('workshops')} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-navy-700 transition-colors">
|
||||||
|
<ArrowLeft size={15} /> Workshops
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={14} className="text-gray-300" />
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px] sm:max-w-none">{workshop?.name || 'New Workshop'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setView(v => v === 'live' ? 'summary' : 'live')}
|
||||||
|
className="text-xs font-medium text-navy-700 border border-navy-200 bg-navy-50 hover:bg-navy-100 px-3 py-1.5 rounded-md transition-colors">
|
||||||
|
{view === 'live' ? 'View Summary' : '← Live Capture'}
|
||||||
|
</button>
|
||||||
|
{view === 'live' && (
|
||||||
|
<button onClick={generateSummary} disabled={summaryLoading || captures.length === 0}
|
||||||
|
className="flex items-center gap-1.5 bg-navy-700 hover:bg-navy-800 text-white text-xs font-medium px-3 py-1.5 rounded-md transition-colors disabled:opacity-40">
|
||||||
|
{summaryLoading ? <><Loader2 size={12} className="animate-spin" /> Summarising…</> : <><Zap size={12} /> Generate Summary</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{view === 'summary' && summary && (
|
||||||
|
<button onClick={handleCopy}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-navy-700 border border-navy-200 bg-navy-50 hover:bg-navy-100 px-3 py-1.5 rounded-md transition-colors">
|
||||||
|
{copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy Summary</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'live' ? (
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-6 grid md:grid-cols-5 gap-6">
|
||||||
|
{/* Left — Live capture */}
|
||||||
|
<div className="md:col-span-3 space-y-4">
|
||||||
|
{/* Objective */}
|
||||||
|
{workshop?.objective && (
|
||||||
|
<div className="bg-navy-50 border border-navy-100 rounded-lg px-4 py-3">
|
||||||
|
<div className="text-[10px] font-semibold text-navy-600 uppercase tracking-wider mb-0.5">Workshop Objective</div>
|
||||||
|
<p className="text-sm text-navy-800">{workshop.objective}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Tag & Capture</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CAPTURE_TYPES.map(t => (
|
||||||
|
<button key={t.type} onClick={() => setActiveType(t.type)}
|
||||||
|
className={`text-xs font-medium px-2.5 py-1.5 rounded border transition-colors ${t.color} ${activeType === t.type ? 'ring-2 ring-offset-1 ring-navy-400' : ''}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 p-3">
|
||||||
|
<textarea
|
||||||
|
value={liveNote}
|
||||||
|
onChange={e => setLiveNote(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addCapture(); }}}
|
||||||
|
placeholder={`Type a ${activeType}… (Enter to add)`}
|
||||||
|
className="flex-1 text-sm text-gray-800 placeholder-gray-300 resize-none focus:outline-none min-h-[72px] leading-relaxed"
|
||||||
|
/>
|
||||||
|
<button onClick={addCapture} disabled={!liveNote.trim()}
|
||||||
|
className="shrink-0 bg-navy-700 hover:bg-navy-800 text-white text-xs font-medium px-3 py-2 rounded-md transition-colors disabled:opacity-40">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Captures list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{captures.length === 0 ? (
|
||||||
|
<div className="bg-white border border-dashed border-gray-200 rounded-lg p-8 text-center text-sm text-gray-300">
|
||||||
|
Captures will appear here as you add them…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
captures.map((c, i) => {
|
||||||
|
const s = STYLE[c.type] || STYLE.decision;
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex items-start gap-3 p-3 rounded-lg border ${s.bg} ${s.border}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${s.dot}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-wider ${s.text} mr-2`}>{s.label}</span>
|
||||||
|
{c.timestamp && <span className="text-[10px] text-gray-400 font-mono">{c.timestamp}</span>}
|
||||||
|
<p className="text-sm text-gray-800 mt-0.5">{c.text}</p>
|
||||||
|
{c.stakeholder && <div className="text-xs text-gray-400 mt-0.5">— {c.stakeholder}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeCapture(i)} className="text-gray-300 hover:text-red-400 text-xs shrink-0">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — AI Assist */}
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<Zap size={12} className="text-navy-600" /> Live AI Assist
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<input value={aiInput} onChange={e => setAiInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAiAssist(); }}
|
||||||
|
placeholder="e.g. Two execs disagree on priorities…"
|
||||||
|
className="w-full text-sm border border-gray-200 rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-navy-700 placeholder-gray-300" />
|
||||||
|
<button onClick={handleAiAssist} disabled={aiLoading || !aiInput.trim()}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 text-xs font-medium text-navy-700 border border-navy-200 bg-navy-50 hover:bg-navy-100 py-2 rounded-md transition-colors disabled:opacity-40">
|
||||||
|
{aiLoading ? <><Loader2 size={12} className="animate-spin" /> Thinking…</> : 'Get facilitation move →'}
|
||||||
|
</button>
|
||||||
|
{aiResponse && (
|
||||||
|
<div className="bg-navy-50 border border-navy-100 rounded-md p-3 text-sm text-navy-800 leading-relaxed animate-fade-in">
|
||||||
|
{aiResponse}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stakeholders */}
|
||||||
|
{workshop?.stakeholders && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-2">Stakeholders</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{workshop.stakeholders.map(s => (
|
||||||
|
<span key={s} className="text-xs bg-gray-100 text-gray-600 px-2.5 py-1 rounded-full">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Summary view */
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-8 space-y-6">
|
||||||
|
{summaryLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-16 text-gray-400">
|
||||||
|
<Loader2 size={20} className="animate-spin text-navy-600" />
|
||||||
|
<span className="text-sm">Generating structured summary…</span>
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<>
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">AI-Generated Workshop Summary</h2>
|
||||||
|
<span className="text-xs text-gray-400">{workshop?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{summary}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grouped captures */}
|
||||||
|
{groupedCaptures.map(group => {
|
||||||
|
const s = STYLE[group.type] || STYLE.decision;
|
||||||
|
return (
|
||||||
|
<div key={group.type} className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-gray-100">
|
||||||
|
<h3 className={`text-sm font-semibold ${s.text}`}>{s.label}s ({group.items.length})</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{group.items.map((item, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 px-5 py-3">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${s.dot}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-700">{item.text}</p>
|
||||||
|
{item.stakeholder && <div className="text-xs text-gray-400 mt-0.5">— {item.stakeholder} · {item.timestamp}</div>}
|
||||||
|
{item.owner && <div className="text-xs text-gray-400 mt-0.5">Owner: {item.owner} · Due: {item.due}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-sm text-gray-400">
|
||||||
|
<p>No summary yet. Go to Live Capture and click "Generate Summary".</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Calendar, Users, CheckCircle, AlertTriangle, Zap, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
import { WORKSHOPS } from '../data/mock.js';
|
||||||
|
|
||||||
|
const CAPTURE_STYLE = {
|
||||||
|
decision: { icon: '✓', bg: 'bg-green-50', text: 'text-green-700', label: 'Decision' },
|
||||||
|
tension: { icon: '⚡', bg: 'bg-red-50', text: 'text-red-700', label: 'Tension' },
|
||||||
|
question: { icon: '?', bg: 'bg-amber-50', text: 'text-amber-700', label: 'Question' },
|
||||||
|
'next-step':{ icon: '→', bg: 'bg-blue-50', text: 'text-blue-700', label: 'Next Step' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WorkshopList({ persona, engagement, navigate }) {
|
||||||
|
const workshops = WORKSHOPS.filter(w => w.engagementId === engagement?.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-4xl mx-auto animate-fade-in space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Workshops</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">{engagement?.client} — {workshops.length} workshop{workshops.length !== 1 ? 's' : ''} recorded</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('workshop-live', { workshop: null })}
|
||||||
|
className="flex items-center gap-1.5 bg-navy-700 hover:bg-navy-800 text-white text-sm font-medium px-4 py-2 rounded-md transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Plus size={15} /> New Workshop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workshops.length === 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
|
||||||
|
<Zap size={24} className="text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-gray-500 mb-4">No workshops recorded yet for this engagement.</p>
|
||||||
|
<button onClick={() => navigate('workshop-live', { workshop: null })}
|
||||||
|
className="text-sm text-navy-700 hover:underline">Start your first workshop →</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{workshops.map(w => {
|
||||||
|
const decisions = w.captures.filter(c => c.type === 'decision');
|
||||||
|
const tensions = w.captures.filter(c => c.type === 'tension');
|
||||||
|
const questions = w.captures.filter(c => c.type === 'question');
|
||||||
|
const nextSteps = w.captures.filter(c => c.type === 'next-step');
|
||||||
|
return (
|
||||||
|
<div key={w.id} className="bg-white border border-gray-200 rounded-lg overflow-hidden hover:border-navy-200 transition-colors">
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">{w.name}</h2>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
||||||
|
<span className="flex items-center gap-1"><Calendar size={11} /> {w.date}</span>
|
||||||
|
<span className="flex items-center gap-1"><Users size={11} /> {w.stakeholders.length} stakeholders</span>
|
||||||
|
<span>{w.duration} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs bg-green-50 text-green-700 px-2.5 py-1 rounded font-medium">Completed</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('workshop-live', { workshop: w })}
|
||||||
|
className="flex items-center gap-1 text-xs text-navy-700 border border-navy-200 bg-navy-50 hover:bg-navy-100 px-2.5 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
View <ChevronRight size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed mb-4 line-clamp-2">{w.aiSummary}</p>
|
||||||
|
|
||||||
|
{/* Capture summary chips */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{decisions.length > 0 && <span className="text-xs bg-green-50 text-green-700 px-2.5 py-1 rounded-full font-medium">✓ {decisions.length} decision{decisions.length>1?'s':''}</span>}
|
||||||
|
{tensions.length > 0 && <span className="text-xs bg-red-50 text-red-600 px-2.5 py-1 rounded-full font-medium">⚡ {tensions.length} tension{tensions.length>1?'s':''}</span>}
|
||||||
|
{questions.length > 0 && <span className="text-xs bg-amber-50 text-amber-700 px-2.5 py-1 rounded-full font-medium">? {questions.length} open question{questions.length>1?'s':''}</span>}
|
||||||
|
{nextSteps.length > 0 && <span className="text-xs bg-blue-50 text-blue-700 px-2.5 py-1 rounded-full font-medium">→ {nextSteps.length} next step{nextSteps.length>1?'s':''}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-22
@@ -1,36 +1,38 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'sans-serif'],
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
slate: {
|
navy: {
|
||||||
850: '#151e2e',
|
50: '#EEF2F7',
|
||||||
900: '#0f172a',
|
100: '#D9E3EF',
|
||||||
950: '#020617',
|
200: '#B3C7DF',
|
||||||
}
|
300: '#8DAACF',
|
||||||
|
400: '#678EBF',
|
||||||
|
500: '#4172AF',
|
||||||
|
600: '#2D5A99',
|
||||||
|
700: '#1B2E4B',
|
||||||
|
800: '#152440',
|
||||||
|
900: '#0E1A34',
|
||||||
|
950: '#070D1A',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
'fade-in': 'fadeIn 0.2s ease-out forwards',
|
||||||
'fade-in-up': 'fadeInUp 0.5s ease-out forwards',
|
'fade-in-up': 'fadeInUp 0.25s ease-out forwards',
|
||||||
|
'slide-in': 'slideIn 0.2s ease-out forwards',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
|
||||||
'0%': { opacity: '0' },
|
fadeInUp: { '0%': { opacity: '0', transform: 'translateY(8px)' },'100%': { opacity: '1', transform: 'translateY(0)' } },
|
||||||
'100%': { opacity: '1' },
|
slideIn: { '0%': { opacity: '0', transform: 'translateX(-8px)' },'100%': { opacity: '1', transform: 'translateX(0)' } },
|
||||||
},
|
},
|
||||||
fadeInUp: {
|
|
||||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user