diff --git a/apps/delta-command/README.md b/apps/delta-command/README.md index aadb763..d956a13 100644 --- a/apps/delta-command/README.md +++ b/apps/delta-command/README.md @@ -46,6 +46,28 @@ The whole app is small on purpose: | `DELTA_API_URL` | `http://127.0.0.1:8000` | Backend URL (used by Next.js server-side and rewrites). | | `DELTA_DATA_PATH` | `backend/config/data.json` | JSON database file. | | `DELTA_HOST` / `DELTA_PORT` | `127.0.0.1` / `8000` | Backend bind address. | +| `LLM_API_KEY` | *(unset)* | Enables the **AI Executive Briefing** on the dashboard. | +| `LLM_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible endpoint (Groq, Ollama, etc.). | +| `LLM_MODEL` | `gpt-4o-mini` | Model name. | + +### AI briefing + +With `LLM_API_KEY` set, a **Generate** button on the dashboard produces a 2–3 paragraph leadership summary of the live pipeline, capacity, and delivery risks. Works with any OpenAI-compatible provider: + +```bash +# OpenAI +export LLM_API_KEY=sk-... + +# Groq +export LLM_API_KEY=gsk_... +export LLM_BASE_URL=https://api.groq.com/openai/v1 +export LLM_MODEL=llama-3.1-70b-versatile + +# Local Ollama +export LLM_API_KEY=ollama +export LLM_BASE_URL=http://localhost:11434/v1 +export LLM_MODEL=llama3.2 +``` ## Production diff --git a/apps/delta-command/backend/README.md b/apps/delta-command/backend/README.md index fe33b3b..e178084 100644 --- a/apps/delta-command/backend/README.md +++ b/apps/delta-command/backend/README.md @@ -18,6 +18,19 @@ uv run delta-command # → http://127.0.0.1:8000 | PATCH | `/api/opportunities` | Update stage (probability auto-set to 100/0 for won/lost). | | PATCH | `/api/projects` | Update status. | | PATCH | `/api/timeline` | Update one weekly utilization cell for one engineer; recomputes engineer.utilization and status. | +| POST | `/api/briefing` | Author an executive briefing via an OpenAI-compatible LLM. Returns `503` if `LLM_API_KEY` is not set. | + +### LLM configuration + +`POST /api/briefing` uses an OpenAI-compatible chat completions endpoint: + +``` +LLM_API_KEY required +LLM_BASE_URL default https://api.openai.com/v1 +LLM_MODEL default gpt-4o-mini +``` + +Works with OpenAI, Groq, Ollama, or any provider that speaks `/v1/chat/completions`. ## Database @@ -40,5 +53,6 @@ delta_command/ ├── json_db.py # atomic file I/O ├── models.py # Pydantic models (snake_case in Python, camelCase in JSON) ├── store.py # domain operations (load/save/update) +├── briefing.py # LLM-authored executive briefing └── main.py # FastAPI routes ``` diff --git a/apps/delta-command/backend/pyproject.toml b/apps/delta-command/backend/pyproject.toml index 3bdbe90..4084e6d 100644 --- a/apps/delta-command/backend/pyproject.toml +++ b/apps/delta-command/backend/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "pydantic>=2.10.0", + "httpx>=0.28.0", ] [project.scripts] @@ -22,7 +23,6 @@ packages = ["src/delta_command"] [dependency-groups] dev = [ - "httpx>=0.28.0", "pytest>=8.3.0", "ruff>=0.11.0", ] diff --git a/apps/delta-command/backend/src/delta_command/briefing.py b/apps/delta-command/backend/src/delta_command/briefing.py new file mode 100644 index 0000000..c70f8c7 --- /dev/null +++ b/apps/delta-command/backend/src/delta_command/briefing.py @@ -0,0 +1,118 @@ +"""LLM-generated executive briefings. + +The backend summarises the current state into a compact prompt, then calls an +OpenAI-compatible chat completions endpoint. Works with OpenAI, Groq, Ollama, +etc. — anything that speaks the /v1/chat/completions protocol. + +Configure via environment: + LLM_API_KEY required (use any placeholder for local Ollama) + LLM_BASE_URL default: https://api.openai.com/v1 + LLM_MODEL default: gpt-4o-mini +""" + +import os + +import httpx + +from delta_command.models import Database, OpportunityStage, ProjectStatus +from delta_command.store import load_database + +SYSTEM_PROMPT = ( + "You are an executive briefing assistant for a delta engineering operations " + "dashboard. Given the current state, write a concise leadership briefing " + "(2-3 short paragraphs, ~150 words total) that highlights: pipeline health, " + "team capacity concerns, and delivery risks. Lead with what needs attention. " + "Use plain prose, no bullet lists, no headings." +) + + +class LLMNotConfigured(RuntimeError): + """Raised when LLM_API_KEY is missing.""" + + +class LLMError(RuntimeError): + """Raised when the upstream provider fails.""" + + +def build_context(db: Database) -> str: + """Compact state summary used as the LLM user prompt.""" + engineers = db.engineers + opps = db.opportunities + projects = db.projects + + active_opps = [o for o in opps if o.stage not in (OpportunityStage.WON, OpportunityStage.LOST)] + late_stage = [o for o in active_opps if o.stage in (OpportunityStage.PROPOSAL, OpportunityStage.NEGOTIATION)] + total_pipeline = sum(o.value for o in active_opps) + weighted = sum(o.value * o.probability / 100 for o in active_opps) + + avg_util = round(sum(e.utilization for e in engineers) / len(engineers)) if engineers else 0 + overallocated = sorted( + (e for e in engineers if e.utilization >= 100), + key=lambda e: -e.utilization, + ) + unassigned = [e for e in engineers if not e.current_projects] + + active_projects = [p for p in projects if p.status in (ProjectStatus.ACTIVE, ProjectStatus.AT_RISK)] + at_risk = [p for p in projects if p.status == ProjectStatus.AT_RISK] + total_budget = sum(p.budget for p in active_projects) or 1 + total_spent = sum(p.spent for p in active_projects) + + lines = [ + f"Team: {len(engineers)} engineers, avg utilization {avg_util}%.", + f"Overallocated ({len(overallocated)}): " + + (", ".join(f"{e.name} at {e.utilization}%" for e in overallocated[:5]) or "none"), + f"Available for assignment: {len(unassigned)}.", + "", + f"Pipeline: {len(active_opps)} active opportunities worth ${total_pipeline/1_000_000:.1f}M " + f"(${weighted/1_000_000:.1f}M weighted). {len(late_stage)} in proposal or negotiation.", + "", + f"Delivery: {len(active_projects)} projects active, {len(at_risk)} at risk. " + f"Budget burn: ${total_spent/1_000_000:.1f}M of ${total_budget/1_000_000:.1f}M " + f"({round(total_spent/total_budget*100)}%).", + ] + if at_risk: + lines.append( + "At-risk projects: " + + ", ".join(f"{p.name} ({p.progress}% complete)" for p in at_risk[:3]) + ) + return "\n".join(lines) + + +async def call_llm(system: str, user: str) -> str: + api_key = os.environ.get("LLM_API_KEY") + if not api_key: + raise LLMNotConfigured( + "LLM_API_KEY is not set. Configure an OpenAI-compatible provider " + "(see backend/README.md)." + ) + base_url = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/") + model = os.environ.get("LLM_MODEL", "gpt-4o-mini") + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.post( + f"{base_url}/chat/completions", + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.4, + }, + ) + except httpx.HTTPError as exc: + raise LLMError(f"LLM request failed: {exc}") from exc + + if response.status_code >= 400: + raise LLMError(f"LLM provider returned {response.status_code}: {response.text[:200]}") + + try: + return response.json()["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError, ValueError) as exc: + raise LLMError(f"Unexpected LLM response shape: {exc}") from exc + + +async def generate_briefing() -> str: + return await call_llm(SYSTEM_PROMPT, build_context(load_database())) diff --git a/apps/delta-command/backend/src/delta_command/main.py b/apps/delta-command/backend/src/delta_command/main.py index 3d87e9b..587693b 100644 --- a/apps/delta-command/backend/src/delta_command/main.py +++ b/apps/delta-command/backend/src/delta_command/main.py @@ -5,6 +5,7 @@ import uvicorn from fastapi import FastAPI, HTTPException +from delta_command.briefing import LLMError, LLMNotConfigured, generate_briefing from delta_command.models import ( Database, Opportunity, @@ -20,7 +21,7 @@ update_timeline_cell, ) -app = FastAPI(title="Delta Command API", version="1.0.0") +app = FastAPI(title="Delta Command API", version="1.1.0") @app.get("/api/state") @@ -53,6 +54,18 @@ def patch_timeline(body: TimelineCellUpdate) -> Database: raise HTTPException(404, str(exc)) from exc +@app.post("/api/briefing") +async def post_briefing() -> dict[str, str]: + """Generate an LLM-authored executive briefing from the current state.""" + try: + briefing = await generate_briefing() + except LLMNotConfigured as exc: + raise HTTPException(503, str(exc)) from exc + except LLMError as exc: + raise HTTPException(502, str(exc)) from exc + return {"briefing": briefing} + + def run() -> None: host = os.environ.get("DELTA_HOST", "127.0.0.1") port = int(os.environ.get("DELTA_PORT", "8000")) diff --git a/apps/delta-command/backend/tests/test_briefing.py b/apps/delta-command/backend/tests/test_briefing.py new file mode 100644 index 0000000..cbe7116 --- /dev/null +++ b/apps/delta-command/backend/tests/test_briefing.py @@ -0,0 +1,88 @@ +from pathlib import Path +from typing import Any + +import httpx +import pytest +from fastapi.testclient import TestClient + +from delta_command.briefing import build_context +from delta_command.main import app +from delta_command.models import Database + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + data_file = tmp_path / "data.json" + source = Path(__file__).resolve().parents[1] / "config" / "data.json" + data_file.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + monkeypatch.setenv("DELTA_DATA_PATH", str(data_file)) + monkeypatch.delenv("LLM_API_KEY", raising=False) + return TestClient(app) + + +def test_build_context_summary_covers_team_pipeline_delivery(client: TestClient) -> None: + db = Database.model_validate(client.get("/api/state").json()) + context = build_context(db) + assert "Team:" in context + assert "Pipeline:" in context + assert "Delivery:" in context + + +def test_briefing_returns_503_when_llm_not_configured(client: TestClient) -> None: + response = client.post("/api/briefing") + assert response.status_code == 503 + assert "LLM_API_KEY" in response.json()["detail"] + + +def test_briefing_returns_generated_text_when_configured( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("LLM_API_KEY", "test-key") + monkeypatch.setenv("LLM_BASE_URL", "https://example.test/v1") + monkeypatch.setenv("LLM_MODEL", "test-model") + + captured: dict[str, Any] = {} + + def fake_handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["auth"] = request.headers.get("authorization") + return httpx.Response( + 200, + json={ + "choices": [ + {"message": {"content": "Pipeline is healthy. Watch Marcus at 116%."}} + ] + }, + ) + + real_async_client = httpx.AsyncClient + + def async_client_factory(*args: Any, **kwargs: Any) -> httpx.AsyncClient: + return real_async_client(transport=httpx.MockTransport(fake_handler)) + + monkeypatch.setattr("delta_command.briefing.httpx.AsyncClient", async_client_factory) + + response = client.post("/api/briefing") + assert response.status_code == 200 + assert "Pipeline is healthy" in response.json()["briefing"] + assert captured["url"] == "https://example.test/v1/chat/completions" + assert captured["auth"] == "Bearer test-key" + + +def test_briefing_returns_502_when_provider_errors( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("LLM_API_KEY", "test-key") + real_async_client = httpx.AsyncClient + + def fake_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="upstream broken") + + monkeypatch.setattr( + "delta_command.briefing.httpx.AsyncClient", + lambda *a, **k: real_async_client(transport=httpx.MockTransport(fake_handler)), + ) + + response = client.post("/api/briefing") + assert response.status_code == 502 + assert "500" in response.json()["detail"] diff --git a/apps/delta-command/backend/uv.lock b/apps/delta-command/backend/uv.lock index d3c2e57..f037cab 100644 --- a/apps/delta-command/backend/uv.lock +++ b/apps/delta-command/backend/uv.lock @@ -69,13 +69,13 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "httpx" }, { name = "pydantic" }, { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "pytest" }, { name = "ruff" }, ] @@ -83,13 +83,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.28.0" }, { name = "pydantic", specifier = ">=2.10.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.0" }, { name = "pytest", specifier = ">=8.3.0" }, { name = "ruff", specifier = ">=0.11.0" }, ] diff --git a/apps/delta-command/src/app/dashboard/page.tsx b/apps/delta-command/src/app/dashboard/page.tsx index ac41d58..4c84ad1 100644 --- a/apps/delta-command/src/app/dashboard/page.tsx +++ b/apps/delta-command/src/app/dashboard/page.tsx @@ -1,37 +1,27 @@ +import Link from "next/link"; +import { DollarSign, FolderKanban, Flame, Users, CalendarClock } from "lucide-react"; + import { Header } from "@/components/layout/Header"; import { KPICard } from "@/components/dashboard/KPICard"; import { PipelineChart } from "@/components/dashboard/PipelineChart"; import { UtilizationChart } from "@/components/dashboard/UtilizationChart"; import { ActionItemsPanel } from "@/components/dashboard/ActionItemsPanel"; -import { - PipelineHealthPanel, - MilestonesPanel, -} from "@/components/dashboard/PipelineHealthPanel"; -import { - DeliveryOverviewPanel, - TeamSnapshotPanel, -} from "@/components/dashboard/DeliveryOverviewPanel"; +import { ExecutiveBriefingPanel } from "@/components/dashboard/ExecutiveBriefingPanel"; +import { MilestonesPanel } from "@/components/dashboard/MilestonesPanel"; import { ProjectCard } from "@/components/projects/ProjectCard"; import { getState } from "@/core/api"; import { buildDashboardInsights } from "@/core/dashboard-analytics"; import { formatCurrency, formatDate } from "@/core/utils"; -import { - DollarSign, - Target, - FolderKanban, - Users, - Layers, - Flame, - CalendarClock, -} from "lucide-react"; -import Link from "next/link"; export const dynamic = "force-dynamic"; export default async function DashboardPage() { - const { engineers, opportunities, projects, lastUpdated } = await getState(); - const insights = buildDashboardInsights(engineers, opportunities, projects); - const { pipeline, delivery, team, actions, milestones } = insights; + const { engineers, opportunities, projects } = await getState(); + const { pipeline, delivery, team, actions, milestones } = buildDashboardInsights( + engineers, + opportunities, + projects + ); const activeOpps = opportunities.filter((o) => !["won", "lost"].includes(o.stage)); const pipelineValue = activeOpps.reduce((s, o) => s + o.value * (o.probability / 100), 0); @@ -40,7 +30,6 @@ export default async function DashboardPage() { ? Math.round(engineers.reduce((s, e) => s + e.utilization, 0) / engineers.length) : 0; const activeProjects = projects.filter((p) => p.status === "active" || p.status === "at_risk"); - const availableCapacity = engineers.filter((e) => e.utilization < 70).length; const atRiskProjects = projects.filter((p) => p.status === "at_risk"); const upcomingCloses = activeOpps @@ -51,13 +40,10 @@ export default async function DashboardPage() { return (
-
+
-
+
- -
+ +
@@ -109,77 +83,24 @@ export default async function DashboardPage() {
-
- - - + +
- - -
-
+ {atRiskProjects.length > 0 && ( +
-
-

Upcoming Closes

-

- {formatCurrency(pipeline.closingValue30Days)} in next 30 days -

-
- View all -
-
- {upcomingCloses.map((opp) => ( -
-
-

{opp.title}

-

{opp.client}

-
- {opp.stage} - - {opp.probability}% - -
-
-
-

{formatCurrency(opp.value)}

-

- - {formatDate(opp.expectedClose)} -

-

- Wtd {formatCurrency(opp.value * (opp.probability / 100))} -

-
-
- ))} -
-
- -
-
-
-

Projects Needing Attention

-

- {atRiskProjects.length > 0 - ? `${atRiskProjects.length} at risk · ${delivery.overdueMilestones} overdue milestones` - : "Active delivery status"} -

-
- View all projects +

Projects Needing Attention

+ + All projects +
- {(atRiskProjects.length > 0 - ? atRiskProjects - : projects.filter((p) => p.status === "active").slice(0, 2) - ).map((project) => ( + {atRiskProjects.map((project) => ( ))}
-
-
+ + )} +
+
+ ); +} + +interface UpcomingClose { + id: string; + title: string; + client: string; + stage: string; + probability: number; + value: number; + expectedClose: string; +} -
+function UpcomingClosesPanel({ + closes, + closingValue, +}: { + closes: UpcomingClose[]; + closingValue: number; +}) { + return ( +
+
+
+

Upcoming Closes

- Last updated {formatDate(lastUpdated.split("T")[0])} · {engineers.length} engineers ·{" "} - {activeOpps.length} active opportunities · {availableCapacity} with capacity below 70% + {formatCurrency(closingValue)} in next 30 days

+ + View all +
+
    + {closes.map((opp) => ( +
  • +
    +

    {opp.title}

    +

    {opp.client}

    +
    + {opp.stage} + {opp.probability}% +
    +
    +
    +

    {formatCurrency(opp.value)}

    +

    + + {formatDate(opp.expectedClose)} +

    +
    +
  • + ))} +
); } diff --git a/apps/delta-command/src/components/dashboard/DeliveryOverviewPanel.tsx b/apps/delta-command/src/components/dashboard/DeliveryOverviewPanel.tsx deleted file mode 100644 index f60fd01..0000000 --- a/apps/delta-command/src/components/dashboard/DeliveryOverviewPanel.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import Link from "next/link"; -import type { DeliveryInsight, TeamInsight } from "@/core/dashboard-analytics"; -import { cn, formatCurrency, utilizationVariant } from "@/core/utils"; -import { Users, Wallet, Clock, UserCheck } from "lucide-react"; - -interface DeliveryOverviewPanelProps { - delivery: DeliveryInsight; -} - -export function DeliveryOverviewPanel({ delivery }: DeliveryOverviewPanelProps) { - const statusRows = [ - { label: "Active", count: delivery.activeCount, color: "bg-accent" }, - { label: "Planning", count: delivery.planningCount, color: "bg-zinc-300" }, - { label: "At risk", count: delivery.atRiskCount, color: "bg-red-500" }, - ]; - const total = statusRows.reduce((s, r) => s + r.count, 0) || 1; - - return ( -
-
-

Delivery Overview

- - View projects - -
- -
-
-
- - Budget burn -
-

{delivery.burnPercent}%

-

- {formatCurrency(delivery.totalSpent)} of {formatCurrency(delivery.totalBudget)} -

-
-
-
- - Due soon -
-

{delivery.milestonesDue14Days}

-

- milestones in 14 days - {delivery.overdueMilestones > 0 && - ` · ${delivery.overdueMilestones} overdue`} -

-
-
- -
- {statusRows.map((row) => - row.count > 0 ? ( -
- ) : null - )} -
-
- {statusRows.map((row) => ( - - - {row.label}: {row.count} - - ))} - {delivery.projectsEnding30Days > 0 && ( - - · {delivery.projectsEnding30Days} ending in 30d - - )} -
-
- ); -} - -interface TeamSnapshotPanelProps { - team: TeamInsight; - teamSize: number; - avgUtilization: number; -} - -export function TeamSnapshotPanel({ - team, - teamSize, - avgUtilization, -}: TeamSnapshotPanelProps) { - return ( -
-
-

Team Capacity

- - View team - -
- -
-
-

- {avgUtilization}% -

-

Avg util

-
-
-

{team.benchCapacityPercent}%

-

Bench

-
-
-

{team.available.length}

-

Available

-
-
- - {team.overallocated.length > 0 && ( -
-

Overallocated

-
    - {team.overallocated.slice(0, 8).map((e) => ( -
  • - - - {e.name.split(" ")[0]} - - {e.utilization}% -
  • - ))} -
- {team.overallocated.length > 8 && ( -

- + {team.overallocated.length - 8} more overallocated -

- )} -
- )} - - {team.unassigned.length > 0 && ( -
-

Ready for assignment

-
    - {team.unassigned.map((e) => ( -
  • - - {e.name.split(" ")[0]} -
  • - ))} -
-
- )} - - {team.overallocated.length === 0 && team.unassigned.length === 0 && ( -

- {teamSize} engineers actively allocated across delivery workstreams. -

- )} -
- ); -} diff --git a/apps/delta-command/src/components/dashboard/ExecutiveBriefingPanel.tsx b/apps/delta-command/src/components/dashboard/ExecutiveBriefingPanel.tsx new file mode 100644 index 0000000..261a381 --- /dev/null +++ b/apps/delta-command/src/components/dashboard/ExecutiveBriefingPanel.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { Sparkles, RefreshCw, AlertCircle } from "lucide-react"; +import { generateBriefing } from "@/core/api"; +import { cn } from "@/core/utils"; + +export function ExecutiveBriefingPanel() { + const [briefing, setBriefing] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleGenerate() { + setLoading(true); + setError(null); + try { + const { briefing } = await generateBriefing(); + setBriefing(briefing); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate briefing"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

+ + AI Executive Briefing +

+

+ Leadership summary generated from live pipeline, delivery, and capacity data +

+
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {briefing ? ( +
+ {briefing} +
+ ) : ( + !error && + !loading && ( +

+ Click Generate to author a + leadership-ready summary. Requires the backend to have{" "} + LLM_API_KEY{" "} + configured (any OpenAI-compatible provider). +

+ ) + )} + + {loading && !briefing && ( +
+ + Composing briefing… +
+ )} +
+ ); +} diff --git a/apps/delta-command/src/components/dashboard/MilestonesPanel.tsx b/apps/delta-command/src/components/dashboard/MilestonesPanel.tsx new file mode 100644 index 0000000..931125d --- /dev/null +++ b/apps/delta-command/src/components/dashboard/MilestonesPanel.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import type { MilestoneItem } from "@/core/dashboard-analytics"; +import { cn, formatShortDate } from "@/core/utils"; + +interface MilestonesPanelProps { + milestones: MilestoneItem[]; +} + +export function MilestonesPanel({ milestones }: MilestonesPanelProps) { + return ( +
+
+

Upcoming Milestones

+ + All projects + +
+ {milestones.length === 0 ? ( +

No open milestones

+ ) : ( +
    + {milestones.slice(0, 5).map((m) => ( +
  • +
    +

    {m.title}

    +

    {m.projectName}

    +
    +
    + + {m.overdue + ? `${Math.abs(m.daysUntil)}d overdue` + : m.daysUntil === 0 + ? "Today" + : `${m.daysUntil}d`} + +

    + {formatShortDate(m.dueDate)} +

    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/delta-command/src/components/dashboard/PipelineHealthPanel.tsx b/apps/delta-command/src/components/dashboard/PipelineHealthPanel.tsx deleted file mode 100644 index 0dbca95..0000000 --- a/apps/delta-command/src/components/dashboard/PipelineHealthPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import Link from "next/link"; -import type { MilestoneItem } from "@/core/dashboard-analytics"; -import type { PipelineInsight } from "@/core/dashboard-analytics"; -import { formatCurrency, formatShortDate, cn } from "@/core/utils"; -import { Handshake, FileText, CalendarClock, BarChart3 } from "lucide-react"; - -interface PipelineHealthPanelProps { - pipeline: PipelineInsight; -} - -export function PipelineHealthPanel({ pipeline }: PipelineHealthPanelProps) { - const stats = [ - { - icon: FileText, - label: "In proposal", - value: String(pipeline.inProposal), - detail: formatCurrency(pipeline.proposalValue), - }, - { - icon: Handshake, - label: "In negotiation", - value: String(pipeline.inNegotiation), - detail: formatCurrency(pipeline.negotiationValue), - }, - { - icon: CalendarClock, - label: "Closing in 30d", - value: String(pipeline.closingWithin30Days), - detail: formatCurrency(pipeline.closingValue30Days), - }, - { - icon: BarChart3, - label: "Avg deal / probability", - value: formatCurrency(pipeline.avgDealSize), - detail: `${pipeline.avgProbability}% avg probability`, - }, - ]; - - return ( -
-
-

Pipeline Health

- - View pipeline - -
-
- {stats.map((stat) => ( -
-
- - {stat.label} -
-

{stat.value}

-

{stat.detail}

-
- ))} -
-
- ); -} - -interface MilestonesPanelProps { - milestones: MilestoneItem[]; -} - -export function MilestonesPanel({ milestones }: MilestonesPanelProps) { - return ( -
-
-

Upcoming Milestones

- - All projects - -
- {milestones.length === 0 ? ( -

No open milestones

- ) : ( -
    - {milestones.map((m) => ( -
  • -
    -

    {m.title}

    -

    - {m.projectName} -

    -
    -
    - - {m.overdue - ? `${Math.abs(m.daysUntil)}d overdue` - : m.daysUntil === 0 - ? "Today" - : `${m.daysUntil}d`} - -

    - {formatShortDate(m.dueDate)} -

    -
    -
  • - ))} -
- )} -
- ); -} diff --git a/apps/delta-command/src/components/layout/Header.tsx b/apps/delta-command/src/components/layout/Header.tsx index c569ea4..0e706b5 100644 --- a/apps/delta-command/src/components/layout/Header.tsx +++ b/apps/delta-command/src/components/layout/Header.tsx @@ -1,48 +1,25 @@ -"use client"; - -import { Bell, Search } from "lucide-react"; import { format } from "date-fns"; interface HeaderProps { title: string; - subtitle?: string; + meta?: string; } -export function Header({ title, subtitle }: HeaderProps) { +/** + * The header is deliberately spartan: a page title, an optional meta line + * (used to show a single actionable number), and today's date. Everything + * else lives on the page itself so the chrome never competes with data. + */ +export function Header({ title, meta }: HeaderProps) { const today = format(new Date(), "MMM d, yyyy"); return (

{title}

- {subtitle &&

{subtitle}

} -
- -
- - {today} - - -
- - -
- - - -
- NF -
+ {meta &&

{meta}

}
+ {today}
); } diff --git a/apps/delta-command/src/components/layout/Sidebar.tsx b/apps/delta-command/src/components/layout/Sidebar.tsx index 0e63768..0b2c3d5 100644 --- a/apps/delta-command/src/components/layout/Sidebar.tsx +++ b/apps/delta-command/src/components/layout/Sidebar.tsx @@ -2,13 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { - LayoutDashboard, - Target, - Users, - FolderKanban, - Zap, -} from "lucide-react"; +import { LayoutDashboard, Target, Users, FolderKanban, Zap } from "lucide-react"; import { cn } from "@/core/utils"; const navigation = [ @@ -24,13 +18,10 @@ export function Sidebar() { return ( ); } diff --git a/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx b/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx index 1f69f64..f44f7dd 100644 --- a/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx +++ b/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx @@ -2,13 +2,13 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { LayoutGrid, List, Trophy, XCircle } from "lucide-react"; + import { Header } from "@/components/layout/Header"; import { PipelineBoard, OpportunityCard } from "@/components/opportunities/PipelineBoard"; import type { Opportunity, OpportunityStage } from "@/core/types"; -import { OPPORTUNITY_STAGES } from "@/core/types"; import { updateOpportunityStage } from "@/core/api"; import { cn, formatCurrency } from "@/core/utils"; -import { LayoutGrid, List, Trophy, XCircle } from "lucide-react"; interface OpportunitiesPageClientProps { initialOpportunities: Opportunity[]; @@ -30,22 +30,23 @@ export function OpportunitiesPageClient({ const activeOpps = opportunities.filter((o) => !["won", "lost"].includes(o.stage)); const wonOpps = opportunities.filter((o) => o.stage === "won"); - const lostOpps = opportunities.filter((o) => o.stage === "lost"); + const lostCount = opportunities.filter((o) => o.stage === "lost").length; const totalValue = activeOpps.reduce((sum, o) => sum + o.value, 0); const weightedValue = activeOpps.reduce( (sum, o) => sum + o.value * (o.probability / 100), 0 ); + const wonValue = wonOpps.reduce((s, o) => s + o.value, 0); return (
-
+

Active Pipeline

{formatCurrency(totalValue)}

@@ -59,57 +60,37 @@ export function OpportunitiesPageClient({
-

Won

+

Won this cycle

+ {lostCount > 0 && ( + + + {lostCount} lost + + )}

- {wonOpps.length} · {formatCurrency(wonOpps.reduce((s, o) => s + o.value, 0))} -

-
-
-
- -

Lost

-
-

- {lostOpps.length} + {wonOpps.length} · {formatCurrency(wonValue)}

-
-
- - -
- -
- {OPPORTUNITY_STAGES.filter((s) => ["won", "lost"].includes(s.id)).map((stage) => { - const count = opportunities.filter((o) => o.stage === stage.id).length; - return ( - - - {stage.label}: {count} - - ); - })} -
+
+ +
{view === "board" ? ( diff --git a/apps/delta-command/src/components/projects/ProjectsPageClient.tsx b/apps/delta-command/src/components/projects/ProjectsPageClient.tsx index 57e6563..d1d252e 100644 --- a/apps/delta-command/src/components/projects/ProjectsPageClient.tsx +++ b/apps/delta-command/src/components/projects/ProjectsPageClient.tsx @@ -2,21 +2,13 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { AlertTriangle } from "lucide-react"; + import { Header } from "@/components/layout/Header"; import { ProjectCard } from "@/components/projects/ProjectCard"; import type { Engineer, Project, ProjectStatus } from "@/core/types"; -import { PROJECT_STATUSES } from "@/core/types"; import { updateProjectStatus } from "@/core/api"; -import { cn, formatCurrency } from "@/core/utils"; -import { FolderKanban, AlertTriangle, CheckCircle2, PauseCircle } from "lucide-react"; - -const STATUS_ICONS = { - active: FolderKanban, - at_risk: AlertTriangle, - planning: PauseCircle, - completed: CheckCircle2, - on_hold: PauseCircle, -} as const; +import { formatCurrency } from "@/core/utils"; interface ProjectsPageClientProps { initialProjects: Project[]; @@ -41,23 +33,7 @@ export function ProjectsPageClient({ const byStatus = (s: ProjectStatus) => projects.filter((p) => p.status === s); const totalBudget = projects.reduce((sum, p) => sum + p.budget, 0); const totalSpent = projects.reduce((sum, p) => sum + p.spent, 0); - - const renderSection = (title: string, items: Project[]) => - items.length > 0 ? ( -
-

{title}

-
- {items.map((project) => ( - - ))} -
-
- ) : null; + const burn = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0; const atRisk = byStatus("at_risk"); const active = byStatus("active"); @@ -68,23 +44,17 @@ export function ProjectsPageClient({
-
+

Total Projects

{projects.length}

-

In Delivery

-

- {inDelivery} -

-
-
-

Total Budget

+

Budget

{formatCurrency(totalBudget)}

@@ -92,53 +62,70 @@ export function ProjectsPageClient({

{formatCurrency(totalSpent)}

-

- {totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0}% of total budget -

+

{burn}% of budget

-
- {PROJECT_STATUSES.map((status) => { - const count = byStatus(status.id).length; - const Icon = STATUS_ICONS[status.id] ?? FolderKanban; - return ( -
- - - {status.label} - - {count} - -
- ); - })} -
- {atRisk.length > 0 && ( -
-

- - At Risk -

-
- {atRisk.map((project) => ( - - ))} -
-
+ } + titleClassName="text-error" + projects={atRisk} + engineerNames={engineerNames} + onStatusChange={handleStatusChange} + /> )} - {renderSection("Active Delivery", active)} - {renderSection("In Planning", planning)} + +
); } + +function ProjectSection({ + title, + titleIcon, + titleClassName, + projects, + engineerNames, + onStatusChange, +}: { + title: string; + titleIcon?: React.ReactNode; + titleClassName?: string; + projects: Project[]; + engineerNames: Record; + onStatusChange: (id: string, status: ProjectStatus) => void; +}) { + if (projects.length === 0) return null; + return ( +
+

+ {titleIcon} + {title} + ({projects.length}) +

+
+ {projects.map((project) => ( + + ))} +
+
+ ); +} diff --git a/apps/delta-command/src/components/team/EngineerCard.tsx b/apps/delta-command/src/components/team/EngineerCard.tsx deleted file mode 100644 index 03d7abc..0000000 --- a/apps/delta-command/src/components/team/EngineerCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { Engineer } from "@/core/types"; -import { - cn, - getInitials, - getStatusBadgeColor, - utilizationVariant, -} from "@/core/utils"; -import { Mail, Briefcase } from "lucide-react"; - -interface EngineerCardProps { - engineer: Engineer; - projectNames?: Record; -} - -export function EngineerCard({ engineer, projectNames = {} }: EngineerCardProps) { - const projects = engineer.currentProjects.map( - (id) => projectNames[id] ?? id - ); - - return ( -
-
-
- {getInitials(engineer.name)} -
-
-

{engineer.name}

-

{engineer.role}

-
- - {engineer.email} -
-
- - {engineer.status} - -
- -
-
- Utilization - - {engineer.utilization}% - -
-
-
-
- {engineer.utilization > 100 && ( -

- {engineer.utilization - 100}% over capacity -

- )} -
- -
- {engineer.skills.map((skill) => ( - - {skill} - - ))} -
- -
-
- - Current Projects ({projects.length}) -
- {projects.length > 0 ? ( -
    - {projects.map((name) => ( -
  • - {name} -
  • - ))} -
- ) : ( -

Available for assignment

- )} -
-
- ); -} diff --git a/apps/delta-command/src/components/team/TeamPageClient.tsx b/apps/delta-command/src/components/team/TeamPageClient.tsx index 38e07ca..725e106 100644 --- a/apps/delta-command/src/components/team/TeamPageClient.tsx +++ b/apps/delta-command/src/components/team/TeamPageClient.tsx @@ -1,35 +1,24 @@ "use client"; import { useMemo, useState } from "react"; +import { AlertTriangle, CheckCircle, Clock, Users } from "lucide-react"; + import { Header } from "@/components/layout/Header"; -import { EngineerCard } from "@/components/team/EngineerCard"; import { TeamFilters, filterEngineers } from "@/components/team/TeamFilters"; import type { TeamFilterState } from "@/components/team/TeamFilters"; import { UtilizationTimelineView } from "@/components/team/UtilizationTimeline"; import type { Engineer, Project } from "@/core/types"; -import { cn, formatNameList, utilizationVariant } from "@/core/utils"; -import { - Users, - AlertTriangle, - CheckCircle, - Clock, - CalendarRange, - LayoutGrid, -} from "lucide-react"; - -type TeamView = "timeline" | "overview"; +import { cn, formatNameList } from "@/core/utils"; interface TeamPageClientProps { engineers: Engineer[]; projects: Project[]; } -export function TeamPageClient({ engineers: initial, projects }: TeamPageClientProps) { +export function TeamPageClient({ engineers: initial }: TeamPageClientProps) { const [engineers, setEngineers] = useState(initial); - const [view, setView] = useState("timeline"); const [filters, setFilters] = useState({ search: "", status: "all" }); - const projectNames = Object.fromEntries(projects.map((p) => [p.id, p.name])); const filteredEngineers = useMemo( () => filterEngineers(engineers, filters), [engineers, filters] @@ -40,52 +29,18 @@ export function TeamPageClient({ engineers: initial, projects }: TeamPageClientP const avgUtil = engineers.length ? Math.round(engineers.reduce((sum, e) => sum + e.utilization, 0) / engineers.length) : 0; - const sortedEngineers = [...filteredEngineers].sort((a, b) => b.utilization - a.utilization); return (
-
-
-
- - -
-

- {view === "timeline" - ? "Click any week cell to update utilization — changes save to data.json" - : "Current-week snapshot and team member details"} -

-
- - - +
- + {formatNameList(overallocated.map((e) => e.name.split(" ")[0]))} {" "} - {overallocated.length === 1 ? "is" : "are"} overallocated this week. Adjust the timeline - to plan redistribution. + {overallocated.length === 1 ? "is" : "are"} overallocated this week.

)} - {view === "timeline" ? ( - e.id))} - onDatabaseChange={(db) => setEngineers(db.engineers)} - /> - ) : ( - <> -
-

Current Week Capacity

-
- {sortedEngineers.map((engineer) => ( -
- - {engineer.name.split(" ")[0]} - -
-
-
- {engineer.utilization >= 30 && `${engineer.utilization}%`} -
-
-
- - {engineer.currentProjects.length} proj - -
- ))} - {sortedEngineers.length === 0 && ( -

- No engineers match your filters. -

- )} -
-
+ -
-

Team Members

-
- {sortedEngineers.map((engineer) => ( - - ))} -
- {sortedEngineers.length === 0 && ( -

- No engineers match your filters. -

- )} -
- - )} + e.id))} + onDatabaseChange={(db) => setEngineers(db.engineers)} + />
); diff --git a/apps/delta-command/src/core/api.ts b/apps/delta-command/src/core/api.ts index f1626be..4538e49 100644 --- a/apps/delta-command/src/core/api.ts +++ b/apps/delta-command/src/core/api.ts @@ -23,7 +23,14 @@ async function apiFetch(path: string, init?: RequestInit): Promise { headers: { "Content-Type": "application/json", ...init?.headers }, }); if (!response.ok) { - throw new Error(`API ${path} failed: ${response.status} ${response.statusText}`); + let detail = response.statusText; + try { + const body = await response.json(); + if (typeof body?.detail === "string") detail = body.detail; + } catch { + // response wasn't JSON; keep statusText + } + throw new Error(detail); } return response.json() as Promise; } @@ -58,3 +65,8 @@ export function updateTimelineCell( body: JSON.stringify({ engineerId, weekStart, utilization, note }), }); } + +/** Ask the LLM to author an executive briefing from the current state. */ +export function generateBriefing(): Promise<{ briefing: string }> { + return apiFetch<{ briefing: string }>("/api/briefing", { method: "POST" }); +} diff --git a/apps/delta-command/src/core/dashboard-analytics.ts b/apps/delta-command/src/core/dashboard-analytics.ts index 576bf91..b584257 100644 --- a/apps/delta-command/src/core/dashboard-analytics.ts +++ b/apps/delta-command/src/core/dashboard-analytics.ts @@ -20,36 +20,23 @@ export interface MilestoneItem { overdue: boolean; } +/** Only the fields actually rendered on the dashboard live here. */ export interface PipelineInsight { - inProposal: number; - proposalValue: number; - lateStage: number; - lateStageValue: number; closingWithin30Days: number; closingValue30Days: number; - inNegotiation: number; - negotiationValue: number; - avgDealSize: number; - avgProbability: number; } export interface DeliveryInsight { totalBudget: number; totalSpent: number; burnPercent: number; - activeCount: number; planningCount: number; atRiskCount: number; - milestonesDue14Days: number; - overdueMilestones: number; - projectsEnding30Days: number; } export interface TeamInsight { overallocated: Engineer[]; - available: Engineer[]; benchCapacityPercent: number; - unassigned: Engineer[]; } export interface DashboardInsights { @@ -60,7 +47,7 @@ export interface DashboardInsights { team: TeamInsight; } -function isActiveOpportunity(o: Opportunity): boolean { +function isActive(o: Opportunity): boolean { return !["won", "lost"].includes(o.stage); } @@ -69,12 +56,7 @@ export function buildDashboardInsights( opportunities: Opportunity[], projects: Project[] ): DashboardInsights { - const activeOpps = opportunities.filter(isActiveOpportunity); - const inProposal = activeOpps.filter((o) => o.stage === "proposal"); - const inNegotiation = activeOpps.filter((o) => o.stage === "negotiation"); - const lateStage = activeOpps.filter((o) => - ["proposal", "negotiation"].includes(o.stage) - ); + const activeOpps = opportunities.filter(isActive); const closingSoon = activeOpps.filter((o) => { const days = daysUntil(o.expectedClose); return days >= 0 && days <= 30; @@ -101,27 +83,21 @@ export function buildDashboardInsights( }; }) ); - const sortedMilestones = [...allMilestones].sort( (a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() ); - - const overdueMilestones = sortedMilestones.filter((m) => m.overdue); - const milestonesDue14 = sortedMilestones.filter( - (m) => !m.overdue && m.daysUntil <= 14 - ); + const overdue = sortedMilestones.filter((m) => m.overdue); + const dueIn14 = sortedMilestones.filter((m) => !m.overdue && m.daysUntil <= 14); const overallocated = engineers.filter((e) => e.status === "overallocated"); - const available = engineers.filter((e) => e.utilization < 70); const unassigned = engineers.filter((e) => e.currentProjects.length === 0); - const benchCapacity = engineers.reduce( - (sum, e) => sum + Math.max(0, 100 - e.utilization), - 0 - ); const benchCapacityPercent = engineers.length > 0 - ? Math.round(benchCapacity / engineers.length) + ? Math.round( + engineers.reduce((sum, e) => sum + Math.max(0, 100 - e.utilization), 0) / + engineers.length + ) : 0; const actions: ActionItem[] = []; @@ -146,7 +122,7 @@ export function buildDashboardInsights( }); } - for (const m of overdueMilestones) { + for (const m of overdue) { actions.push({ id: `ms-over-${m.id}`, severity: "critical", @@ -168,7 +144,7 @@ export function buildDashboardInsights( }); } - for (const m of milestonesDue14) { + for (const m of dueIn14) { actions.push({ id: `ms-soon-${m.id}`, severity: "warning", @@ -178,9 +154,7 @@ export function buildDashboardInsights( }); } - for (const project of activeProjects.filter( - (p) => p.spent / p.budget >= 0.9 - )) { + for (const project of activeProjects.filter((p) => p.spent / p.budget >= 0.9)) { actions.push({ id: `budget-${project.id}`, severity: "warning", @@ -200,59 +174,23 @@ export function buildDashboardInsights( }); } - const severityOrder: Record = { - critical: 0, - warning: 1, - info: 2, - }; + const severityOrder: Record = { critical: 0, warning: 1, info: 2 }; actions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); return { actions, milestones: sortedMilestones.slice(0, 8), pipeline: { - inProposal: inProposal.length, - proposalValue: inProposal.reduce((s, o) => s + o.value, 0), - lateStage: lateStage.length, - lateStageValue: lateStage.reduce((s, o) => s + o.value, 0), closingWithin30Days: closingSoon.length, closingValue30Days: closingSoon.reduce((s, o) => s + o.value, 0), - inNegotiation: inNegotiation.length, - negotiationValue: inNegotiation.reduce((s, o) => s + o.value, 0), - avgDealSize: - activeOpps.length > 0 - ? Math.round( - activeOpps.reduce((s, o) => s + o.value, 0) / activeOpps.length - ) - : 0, - avgProbability: - activeOpps.length > 0 - ? Math.round( - activeOpps.reduce((s, o) => s + o.probability, 0) / - activeOpps.length - ) - : 0, }, delivery: { totalBudget, totalSpent, - burnPercent: - totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0, - activeCount: projects.filter((p) => p.status === "active").length, + burnPercent: totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0, planningCount: projects.filter((p) => p.status === "planning").length, atRiskCount: projects.filter((p) => p.status === "at_risk").length, - milestonesDue14Days: milestonesDue14.length, - overdueMilestones: overdueMilestones.length, - projectsEnding30Days: projects.filter((p) => { - const d = daysUntil(p.endDate); - return d >= 0 && d <= 30 && p.status !== "completed"; - }).length, - }, - team: { - overallocated, - available, - benchCapacityPercent, - unassigned, }, + team: { overallocated, benchCapacityPercent }, }; }