diff --git a/tavro_api/api/routers/insights.py b/tavro_api/api/routers/insights.py index e790d3a..67bc32d 100644 --- a/tavro_api/api/routers/insights.py +++ b/tavro_api/api/routers/insights.py @@ -328,6 +328,46 @@ def _days_since(ts: Any) -> int: return 0 +def _to_dt(ts: Any) -> Optional[datetime]: + if not ts: + return None + try: + dt = ts if isinstance(ts, datetime) else datetime.fromisoformat(str(ts)) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (TypeError, ValueError): + return None + + +def _relative_time(ts: Any) -> str: + dt = _to_dt(ts) + if not dt: + return "Recently" + seconds = max(0, int((datetime.now(timezone.utc) - dt).total_seconds())) + if seconds < 60: + return "Just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + if days == 1: + return "Yesterday" + if days < 7: + return f"{days} days ago" + weeks = days // 7 + if weeks < 5: + return f"{weeks}w ago" + months = days // 30 + if months < 12: + return f"{months}mo ago" + years = days // 365 + return f"{years}y ago" + + # KPI catalog — port of KPI_DEFINITIONS. Each returns (value, target, status). def _kpi_task_completion(perf): # noqa: ANN001 v = 82 + perf * 16 @@ -406,6 +446,14 @@ def _pct(count: int, total: int) -> int: ("ESG & Sustainability", ["organisation", "technology"]), ] +def _profile_dimension_hint(categories: List[str], category_labels: Dict[str, str]) -> str: + labels = [_display(category_labels.get(cat), cat.title()) for cat in categories] + if not labels: + return "a Blueprint dimension" + if len(labels) == 1: + return f"a {labels[0]} dimension" + return f"{', '.join(labels[:-1])}, or {labels[-1]} dimensions" + # --------------------------------------------------------------------------- # SQL @@ -418,6 +466,8 @@ def _pct(count: int, total: int) -> int: a.agent_name, a.agent_description, a.source_system, + a.created_ts, + a.updated_ts, i.environment, i.governance_status, cfg.autonomy_level, @@ -477,11 +527,27 @@ def _pct(count: int, total: int) -> int: """ _USECASES_SQL = f""" -SELECT name, status +SELECT ai_use_case_id, name, status, created_ts, updated_ts FROM {CORE}.ai_use_cases WHERE (tenant_id = :tid OR tenant_id IS NULL) """ +_SPARK_COUNTS_SQL = f""" +SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')::int AS this_week +FROM {CORE}.spark_ideas +WHERE company_id = :cid +""" + +_RECENT_SPARK_SQL = f""" +SELECT idea_id, title, created_at, updated_at +FROM {CORE}.spark_ideas +WHERE company_id = :cid +ORDER BY COALESCE(updated_at, created_at) DESC NULLS LAST +LIMIT :limit +""" + _COMPANY_PICK_SQL = """ SELECT id FROM twin.company ORDER BY updated_at DESC NULLS LAST LIMIT 1 """ @@ -493,6 +559,12 @@ def _pct(count: int, total: int) -> int: WHERE dn.company_id = :cid AND dn.valid_to IS NULL """ +_DIM_TYPE_LABELS_SQL = """ +SELECT category::text AS category, name +FROM twin.dim_type +ORDER BY system_defined DESC NULLS LAST, name +""" + # --------------------------------------------------------------------------- # Endpoint @@ -614,6 +686,15 @@ def _to_risk_agent(a: Dict[str, Any]) -> Dict[str, Any]: for a in agent_rows if _risk_not_triggered(a) ] + spark_total, spark_this_week = await _spark_counts(db, company_id) + use_cases_in_progress = sum( + 1 + for uc in uc_rows + if any(k in _norm(uc.get("status")) for k in ("progress", "build", "develop")) + ) + live_agents = sum(1 for a in agent_rows if _is_prod_env(a.get("environment"))) + need_review = sum(1 for a in agent_rows if _needs_human(a)) + # --- stage gate blockers (every agent) --- stage_gate_blockers = [ { @@ -647,14 +728,22 @@ def _to_risk_agent(a: Dict[str, Any]) -> Dict[str, Any]: # --- company profile (twin) --- company_profile = await _build_company_profile(db, company_id) + recent_activity = await _home_recent_activity(db, company_id, agent_rows, uc_rows) + attention_items = _home_attention_items(agent_rows, uc_rows, company_profile) return { "totals": { + "sparkIdeas": spark_total, + "sparkIdeasThisWeek": spark_this_week, "totalAgents": total_agents, + "liveAgents": live_agents, "totalUseCases": len(uc_rows), + "useCasesInProgress": use_cases_in_progress, "criticalCount": sum(1 for a in agent_rows if a["_risk"] == "critical"), "highRiskCount": sum(1 for a in agent_rows if a["_risk"] == "high"), "hitlOpen": len(hitl), + "openIssues": len(hitl), + "needReview": need_review, }, "agentLifecycle": agent_lifecycle, "useCaseLifecycle": usecase_lifecycle, @@ -667,9 +756,181 @@ def _to_risk_agent(a: Dict[str, Any]) -> Dict[str, Any]: "stageGateBlockers": stage_gate_blockers, "successMetrics": success_metrics, "companyProfile": company_profile, + "homeRecentActivity": recent_activity, + "homeAttentionItems": attention_items, } +async def _spark_counts(db: AsyncSession, company_id: Optional[str]) -> tuple[int, int]: + try: + cid = company_id + if not cid: + row = (await db.execute(text(_COMPANY_PICK_SQL))).first() + cid = str(row[0]) if row else None + if not cid: + return 0, 0 + row = (await db.execute(text(_SPARK_COUNTS_SQL), {"cid": cid})).mappings().first() + if not row: + return 0, 0 + return int(row["total"] or 0), int(row["this_week"] or 0) + except Exception: # noqa: BLE001 + return 0, 0 + + +async def _resolve_company_id(db: AsyncSession, company_id: Optional[str]) -> Optional[str]: + if company_id: + return company_id + row = (await db.execute(text(_COMPANY_PICK_SQL))).first() + return str(row[0]) if row else None + + +async def _home_recent_activity( + db: AsyncSession, + company_id: Optional[str], + agent_rows: List[Dict[str, Any]], + uc_rows: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + events: List[Dict[str, Any]] = [] + + try: + cid = await _resolve_company_id(db, company_id) + if cid: + spark_rows = (await db.execute(text(_RECENT_SPARK_SQL), {"cid": cid, "limit": 4})).mappings().all() + for row in spark_rows: + ts = row.get("updated_at") or row.get("created_at") + title = _display(row.get("title"), "Spark idea") + events.append({ + "id": f"spark:{row.get('idea_id')}", + "text": f"Spark idea added: {title}", + "time": _relative_time(ts), + "dot": "emerald", + "_ts": _to_dt(ts), + }) + except Exception: # noqa: BLE001 + pass + + for uc in uc_rows: + ts = uc.get("updated_ts") or uc.get("created_ts") + status = _display(uc.get("status"), "") + name = _display(uc.get("name"), "AI use case") + if status: + text_value = f"{name} moved to {status} stage" + else: + text_value = f"AI use case updated: {name}" + events.append({ + "id": f"usecase:{uc.get('ai_use_case_id')}", + "text": text_value, + "time": _relative_time(ts), + "dot": "violet", + "_ts": _to_dt(ts), + }) + + for agent in agent_rows: + risk_ts = agent.get("assessment_ts") + if risk_ts and _has_resolved_risk(agent): + risk = _display(agent.get("blended_risk_class") or agent.get("aivss_class"), "risk") + events.append({ + "id": f"agent-risk:{agent.get('agent_id')}", + "text": f"{_display(agent.get('agent_name'), 'Agent')} risk classified as {risk}", + "time": _relative_time(risk_ts), + "dot": "amber" if agent.get("_risk") in ("critical", "high", "medium") else "emerald", + "_ts": _to_dt(risk_ts), + }) + + ts = agent.get("updated_ts") or agent.get("created_ts") + if ts: + events.append({ + "id": f"agent:{agent.get('agent_id')}", + "text": f"Agent updated: {_display(agent.get('agent_name'), 'Untitled agent')}", + "time": _relative_time(ts), + "dot": "emerald" if _is_prod_env(agent.get("environment")) else "violet", + "_ts": _to_dt(ts), + }) + + events.sort(key=lambda e: e.get("_ts") or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + return [{k: v for k, v in event.items() if k != "_ts"} for event in events[:4]] + + +def _home_attention_items( + agent_rows: List[Dict[str, Any]], + uc_rows: List[Dict[str, Any]], + company_profile: Dict[str, Any], +) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + + review_agents = sorted( + [a for a in agent_rows if _needs_human(a)], + key=lambda a: _to_dt(a.get("updated_ts")) or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + for agent in review_agents: + status = _display(agent.get("governance_status") or agent.get("risk_state"), "review required") + items.append({ + "id": f"agent-review:{agent.get('agent_id')}", + "badge": "Approval", + "text": f"{_display(agent.get('agent_name'), 'Agent')} - {status}", + "action": "Review", + "route": f"/agent/{agent.get('agent_id')}", + }) + + risk_agents = sorted( + [a for a in agent_rows if a.get("_risk") in ("critical", "high")], + key=lambda a: a.get("_score") or 0, + reverse=True, + ) + for agent in risk_agents: + items.append({ + "id": f"agent-risk:{agent.get('agent_id')}", + "badge": "Risk", + "text": f"{_display(agent.get('agent_name'), 'Agent')} - {_display(agent.get('_risk'), 'risk')} risk requires review", + "action": "Review", + "route": f"/agent/{agent.get('agent_id')}", + }) + + for agent in [a for a in agent_rows if _risk_not_triggered(a)]: + items.append({ + "id": f"agent-unassessed:{agent.get('agent_id')}", + "badge": "Issue", + "text": f"{_display(agent.get('agent_name'), 'Agent')} - risk assessment not yet triggered", + "action": "Review", + "route": f"/agent/{agent.get('agent_id')}", + }) + + for uc in uc_rows: + status = _norm(uc.get("status")) + if any(k in status for k in ("pending", "review", "approval", "approve")): + items.append({ + "id": f"usecase-review:{uc.get('ai_use_case_id')}", + "badge": "Approval", + "text": f"{_display(uc.get('name'), 'AI use case')} - {uc.get('status') or 'review pending'}", + "action": "Review", + "route": f"/use-case/{uc.get('ai_use_case_id')}", + }) + + for gap in company_profile.get("gaps", [])[:2]: + area = _display(gap.get("area"), "Profile") + dimension_hint = _display(gap.get("dimensionHint"), "Blueprint dimension") + items.append({ + "id": f"blueprint-gap:{gap.get('id')}", + "badge": "Incomplete", + "text": f"Blueprint - add {dimension_hint} for {area}", + "action": "Complete", + "route": "/blueprint", + }) + + seen: set[str] = set() + unique: List[Dict[str, Any]] = [] + for item in items: + item_id = str(item.get("id") or item.get("text")) + if item_id in seen: + continue + seen.add(item_id) + unique.append(item) + if len(unique) >= 4: + break + return unique + + async def _build_company_profile(db: AsyncSession, company_id: Optional[str]) -> Dict[str, Any]: empty = {"hasActiveCompany": False, "overallPct": 0, "sections": [], "gaps": [], "refreshes": []} try: @@ -680,9 +941,17 @@ async def _build_company_profile(db: AsyncSession, company_id: Optional[str]) -> if not cid: return empty nodes = (await db.execute(text(_PROFILE_NODES_SQL), {"cid": cid})).mappings().all() + dim_type_rows = (await db.execute(text(_DIM_TYPE_LABELS_SQL))).mappings().all() except Exception: # noqa: BLE001 return empty + category_labels: Dict[str, str] = {} + for row in dim_type_rows: + category = _display(row.get("category"), "") + name = _display(row.get("name"), "") + if category and name and category not in category_labels: + category_labels[category] = name + by_category: Dict[str, List[Any]] = {} for n in nodes: by_category.setdefault(n["category"], []).append(n["updated_at"]) @@ -703,6 +972,7 @@ async def _build_company_profile(db: AsyncSession, company_id: Optional[str]) -> "id": f"{s['label']}-{i}", "gap": f"{s['label']} dimensions missing", "area": s["label"], + "dimensionHint": _profile_dimension_hint(_PROFILE_SECTIONS[i][1], category_labels), "severity": "high" if i < 2 else "medium", } for i, s in enumerate(sections) if s["pct"] == 0 diff --git a/tavro_app/src/components/AddDimNodeModal.tsx b/tavro_app/src/components/AddDimNodeModal.tsx index d5ffff5..83b3779 100644 --- a/tavro_app/src/components/AddDimNodeModal.tsx +++ b/tavro_app/src/components/AddDimNodeModal.tsx @@ -8,6 +8,7 @@ import { blueprintApi } from '../services/blueprintApi'; import { useBlueprint } from '../context/BlueprintContext'; import type { DimCategory, VisibilityLevel, DimType } from '../types/blueprint'; import { CATEGORY_PALETTE, CATEGORY_LABELS } from '../types/blueprint'; +import { portalActivity } from '../services/portalActivity'; interface AddDimNodeModalProps { onClose: () => void; @@ -37,6 +38,19 @@ const REL_TYPES = [ 'enables', 'part_of', 'governed_by', 'replaced_by', 'custom', ]; +const DIM_CATEGORY_OPTIONS: DimCategory[] = [ + 'profile', + 'strategy', + 'organisation', + 'finance', + 'risk', + 'application', + 'process', + 'integration', + 'technology', + 'custom', +]; + const AddDimNodeModal: React.FC = ({ onClose, onCreated, defaultCategory, }) => { @@ -119,6 +133,7 @@ const AddDimNodeModal: React.FC = ({ if (attachments.length > 0) { await Promise.all(attachments.map(f => blueprintApi.uploadAttachment(created.id, f))); } + portalActivity.record(`Added blueprint dimension: ${created.label || label.trim()}`, 'emerald'); onCreated(); onClose(); } catch (err: any) { @@ -159,7 +174,7 @@ const AddDimNodeModal: React.FC = ({ {/* Category */}
- {(['strategy', 'organisation', 'finance', 'risk', 'application', 'process', 'integration', 'custom'] as DimCategory[]).map(cat => { + {DIM_CATEGORY_OPTIONS.map(cat => { const p = CATEGORY_PALETTE[cat]; const active = category === cat; return ( diff --git a/tavro_app/src/components/AgentHeader.tsx b/tavro_app/src/components/AgentHeader.tsx index a36f1c8..ce54399 100644 --- a/tavro_app/src/components/AgentHeader.tsx +++ b/tavro_app/src/components/AgentHeader.tsx @@ -227,6 +227,7 @@ const AgentHeader: React.FC = ({ )}
+ ); }; diff --git a/tavro_app/src/components/EditAgentModal.tsx b/tavro_app/src/components/EditAgentModal.tsx index 0018aa9..7832b28 100644 --- a/tavro_app/src/components/EditAgentModal.tsx +++ b/tavro_app/src/components/EditAgentModal.tsx @@ -35,11 +35,14 @@ const EditAgentModal: React.FC = ({ agent, open, onClose, o setSaving(true); setError(null); try { - await agentApi.updateAgent(agentId, { - agent_name: name.trim() || undefined, - description: description.trim() || undefined, - instruction: instruction.trim() || undefined, - }); + const currentName = agent.name ?? ''; + const currentDescription = agent.description ?? ''; + const currentInstruction = agent.identification?.instruction ?? ''; + const payload: { agent_name?: string; description?: string; instruction?: string } = {}; + if (name.trim() !== currentName) payload.agent_name = name.trim() || undefined; + if (description.trim() !== currentDescription) payload.description = description.trim() || undefined; + if (instruction.trim() !== currentInstruction) payload.instruction = instruction.trim() || undefined; + await agentApi.updateAgent(agentId, payload, currentName); const updated = { name: name.trim(), description: description.trim(), diff --git a/tavro_app/src/components/EditUseCaseModal.tsx b/tavro_app/src/components/EditUseCaseModal.tsx index 49f7d82..2872bfa 100644 --- a/tavro_app/src/components/EditUseCaseModal.tsx +++ b/tavro_app/src/components/EditUseCaseModal.tsx @@ -54,15 +54,35 @@ const EditUseCaseModal: React.FC = ({ useCase, open, onCl setSaving(true); setError(null); try { - await useCaseApi.updateUseCase(useCaseId, { - title: title.trim() || undefined, - description: description.trim() || undefined, - business_problem_statement: problemStatement.trim() || undefined, - expected_benefits: expectedBenefits.trim() || undefined, - priority: priority || undefined, - solution_approach: solutionApproach.trim(), - use_case_owner: owner.trim() || undefined, - }); + const payload: any = { + __activityName: uc.name ?? uc.title ?? useCaseId, + }; + const currentTitle = String(uc.name ?? uc.title ?? '').trim(); + const currentDescription = String(uc.description ?? '').trim(); + const currentProblemStatement = String(uc.problem_statement ?? uc.business_problem_statement ?? '').trim(); + const currentExpectedBenefits = String(uc.expected_benefits ?? '').trim(); + const currentPriority = String(uc.priority ?? '3 - Moderate'); + const currentSolutionApproach = String(uc.solution_approach ?? '').trim(); + const currentOwner = String(uc.owner ?? uc.use_case_owner ?? '').trim(); + + const nextTitle = title.trim(); + const nextDescription = description.trim(); + const nextProblemStatement = problemStatement.trim(); + const nextExpectedBenefits = expectedBenefits.trim(); + const nextSolutionApproach = solutionApproach.trim(); + const nextOwner = owner.trim(); + + if (nextTitle !== currentTitle) payload.title = nextTitle || undefined; + if (nextDescription !== currentDescription) payload.description = nextDescription || undefined; + if (nextProblemStatement !== currentProblemStatement) payload.business_problem_statement = nextProblemStatement || undefined; + if (nextExpectedBenefits !== currentExpectedBenefits) payload.expected_benefits = nextExpectedBenefits || undefined; + if (priority !== currentPriority) payload.priority = priority || undefined; + if (nextSolutionApproach !== currentSolutionApproach) payload.solution_approach = nextSolutionApproach || undefined; + if (nextOwner !== currentOwner) payload.use_case_owner = nextOwner || undefined; + + if (Object.keys(payload).length > 1) { + await useCaseApi.updateUseCase(useCaseId, payload); + } const updated = { title: title.trim(), description: description.trim(), diff --git a/tavro_app/src/components/Layout.tsx b/tavro_app/src/components/Layout.tsx index e421adb..e83e2e8 100644 --- a/tavro_app/src/components/Layout.tsx +++ b/tavro_app/src/components/Layout.tsx @@ -17,6 +17,7 @@ import { useUseCases } from '../context/UseCaseContext'; import { useBlueprint } from '../context/BlueprintContext'; import { businessRelationsApi } from '../services/businessRelationsApi'; import { aiModelApi } from '../services/aiModelApi'; +import { portalActivity } from '../services/portalActivity'; const TAVRO_VERSION = 'v.3.1'; import { mcpClient } from '../services/mcpClient'; import { clearAllSessions } from '../store/chatSessionStore'; @@ -81,6 +82,36 @@ const Layout: React.FC = () => { return () => window.removeEventListener('tavro:catalog-item-changed', fetchCatalogCounts); }, [fetchCatalogCounts]); + useEffect(() => { + const useCaseCreated = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + const name = detail.name || detail.title || detail.use_case_name || detail.use_case_id || 'AI use case'; + portalActivity.record(`Created AI use case: ${name}`, 'emerald'); + }; + const agentCreated = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + const agent = detail.agent ?? detail.result ?? detail; + const args = detail.args ?? {}; + const name = args.agent_name || agent.agent_name || agent.name || agent.agent_id || 'agent'; + portalActivity.record(`Created agent: ${name}`, 'emerald'); + }; + const agentArtifactsGenerated = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + const agent = detail.agent ?? detail; + const name = agent.agent_name || agent.name || agent.agent_id || 'agent'; + portalActivity.record(`Generated artifacts for ${name}`, 'amber'); + }; + + window.addEventListener('tavro:usecase-created', useCaseCreated); + window.addEventListener('tavro:agent-created', agentCreated); + window.addEventListener('tavro:agent-artifacts-generated', agentArtifactsGenerated); + return () => { + window.removeEventListener('tavro:usecase-created', useCaseCreated); + window.removeEventListener('tavro:agent-created', agentCreated); + window.removeEventListener('tavro:agent-artifacts-generated', agentArtifactsGenerated); + }; + }, []); + // ── Right panel state ──────────────────────────────────────────────────── const [activePanel, setActivePanel] = useState(null); const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH); @@ -588,4 +619,4 @@ const Layout: React.FC = () => { ); }; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/tavro_app/src/components/LoadAIUseCaseModal.tsx b/tavro_app/src/components/LoadAIUseCaseModal.tsx index fd88847..906d8e4 100644 --- a/tavro_app/src/components/LoadAIUseCaseModal.tsx +++ b/tavro_app/src/components/LoadAIUseCaseModal.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { Upload, X, FileJson, AlertCircle, CheckCircle2, Loader2, Trash2, FolderOpen, Link2 } from 'lucide-react'; import { useCaseApi } from '../services/useCaseApi'; import { driveApi } from '../services/driveApi'; +import { portalActivity } from '../services/portalActivity'; interface LoadAIUseCaseModalProps { onClose: () => void; @@ -99,6 +100,12 @@ const LoadAIUseCaseModal: React.FC = ({ onClose, onSucc try { const result = await driveApi.importFromDrive(url); setDriveSuccess(result.message); + if (result.use_cases_imported > 0) { + portalActivity.record( + `Loaded ${result.use_cases_imported} AI use case${result.use_cases_imported === 1 ? '' : 's'} from Google Drive`, + 'emerald', + ); + } onSuccess(); } catch (err: any) { setDriveError(err?.message ?? 'Drive import failed. Please try again.'); diff --git a/tavro_app/src/pages/AgentViewPage.tsx b/tavro_app/src/pages/AgentViewPage.tsx index a216b10..85fdb99 100644 --- a/tavro_app/src/pages/AgentViewPage.tsx +++ b/tavro_app/src/pages/AgentViewPage.tsx @@ -563,11 +563,14 @@ const AgentViewPage: React.FC = () => { setEditError(null); try { const agentId = agent.identification?.agent_id ?? agent.name; - await agentApi.updateAgent(agentId, { - agent_name: editName.trim() || undefined, - description: editDescription.trim() || undefined, - instruction: editInstruction.trim() || undefined, - }); + const currentName = agent.name ?? ''; + const currentDescription = agent.description ?? ''; + const currentInstruction = agent.identification?.instruction ?? ''; + const payload: import('../services/agentApi').AgentUpdatePayload = {}; + if (editName.trim() !== currentName) payload.agent_name = editName.trim() || undefined; + if (editDescription.trim() !== currentDescription) payload.description = editDescription.trim() || undefined; + if (editInstruction.trim() !== currentInstruction) payload.instruction = editInstruction.trim() || undefined; + await agentApi.updateAgent(agentId, payload, currentName); handleAgentSaved({ name: editName.trim(), description: editDescription.trim(), @@ -614,11 +617,12 @@ const AgentViewPage: React.FC = () => { setEditError(null); try { const agentId = agent.identification?.agent_id ?? agent.name; - await agentApi.updateAgent(agentId, { - agent_name: nextName || undefined, - description: nextDescription || undefined, - instruction: nextInstruction || undefined, - }); + const currentName = agent.name ?? ''; + const inlinePayload: import('../services/agentApi').AgentUpdatePayload = + inlineEdit.field === 'name' ? { agent_name: nextName || undefined } + : inlineEdit.field === 'description' ? { description: nextDescription || undefined } + : { instruction: nextInstruction || undefined }; + await agentApi.updateAgent(agentId, inlinePayload, currentName); handleAgentSaved({ name: nextName, description: nextDescription, diff --git a/tavro_app/src/pages/AiModelViewPage.tsx b/tavro_app/src/pages/AiModelViewPage.tsx index 7679e7f..064d065 100644 --- a/tavro_app/src/pages/AiModelViewPage.tsx +++ b/tavro_app/src/pages/AiModelViewPage.tsx @@ -96,6 +96,18 @@ const buildPayload = (form: FormState): AiModelUpsertPayload => { return payload as AiModelUpsertPayload; }; +const changedPayload = (current: FormState, next: FormState): AiModelUpsertPayload => { + const currentPayload = buildPayload(current); + const nextPayload = buildPayload(next); + const changed: Record = {}; + FIELD_KEYS.forEach(key => { + if ((nextPayload as Record)[key] !== (currentPayload as Record)[key]) { + changed[key] = (nextPayload as Record)[key] ?? null; + } + }); + return changed as AiModelUpsertPayload; +}; + const Field: React.FC<{ label: string; children: React.ReactNode; full?: boolean }> = ({ label, children, full }) => (
@@ -343,7 +355,13 @@ const AiModelViewPage: React.FC = () => { navigate(`/ai-models/${encodeURIComponent(created.ai_model_id)}`, { replace: true }); return; } - await aiModelApi.updateModel(model!.ai_model_id, payload); + const changed = changedPayload(formFromModel(model!), form); + if (Object.keys(changed).length === 0) { + setEditing(false); + setInlineEdit(null); + return; + } + await aiModelApi.updateModel(model!.ai_model_id, changed, model!.model_name ?? undefined); const fresh = await aiModelApi.getModel(model!.ai_model_id); setModel(fresh); setForm(formFromModel(fresh)); @@ -400,7 +418,12 @@ const AiModelViewPage: React.FC = () => { setInlineSaving(inlineEdit.field); setActionError(null); try { - await aiModelApi.updateModel(model.ai_model_id, buildPayload(nextForm)); + const changed = changedPayload(formFromModel(model), nextForm); + if (Object.keys(changed).length === 0) { + setInlineEdit(null); + return; + } + await aiModelApi.updateModel(model.ai_model_id, changed, model.model_name ?? undefined); const fresh = await aiModelApi.getModel(model.ai_model_id); setModel(fresh); setForm(formFromModel(fresh)); diff --git a/tavro_app/src/pages/BusinessApplicationViewPage.tsx b/tavro_app/src/pages/BusinessApplicationViewPage.tsx index 905fcfc..b9d7d87 100644 --- a/tavro_app/src/pages/BusinessApplicationViewPage.tsx +++ b/tavro_app/src/pages/BusinessApplicationViewPage.tsx @@ -221,6 +221,21 @@ const buildApplicationPayload = (form: ApplicationFormState): BusinessApplicatio latest_release_documentation_link: toNullable(form.latest_release_documentation_link), }); +const changedApplicationPayload = ( + current: ApplicationFormState, + next: ApplicationFormState, +): BusinessApplicationUpsertPayload => { + const currentPayload = buildApplicationPayload(current); + const nextPayload = buildApplicationPayload(next); + const changed: BusinessApplicationUpsertPayload = {}; + (Object.keys(nextPayload) as Array).forEach(key => { + if (nextPayload[key] !== currentPayload[key]) { + (changed as Record)[key] = nextPayload[key] ?? null; + } + }); + return changed; +}; + const labelFromOptions = (value: string, options: Option[]): string => { if (!value) return 'N/A'; const found = options.find(o => o.value === value); @@ -463,9 +478,15 @@ const BusinessApplicationViewPage: React.FC = () => { setInlineSaving(inlineEdit.field); setActionError(null); try { + const changedPayload = changedApplicationPayload(formFromApplication(application), nextForm); + if (Object.keys(changedPayload).length === 0) { + setInlineEdit(null); + setAttemptedSave(false); + return; + } const updated = await businessRelationsApi.updateApplication( application.business_application_id, - buildApplicationPayload(nextForm), + changedPayload, ); setApplication(updated); setForm(formFromApplication(updated)); @@ -612,7 +633,14 @@ const BusinessApplicationViewPage: React.FC = () => { return; } if (!application) return; - const updated = await businessRelationsApi.updateApplication(application.business_application_id, payload); + const changedPayload = changedApplicationPayload(formFromApplication(application), form); + if (Object.keys(changedPayload).length === 0) { + setAttemptedSave(false); + setInlineEdit(null); + setEditing(false); + return; + } + const updated = await businessRelationsApi.updateApplication(application.business_application_id, changedPayload); setApplication(updated); setForm(formFromApplication(updated)); setAttemptedSave(false); diff --git a/tavro_app/src/pages/BusinessProcessViewPage.tsx b/tavro_app/src/pages/BusinessProcessViewPage.tsx index 7517fde..f36c184 100644 --- a/tavro_app/src/pages/BusinessProcessViewPage.tsx +++ b/tavro_app/src/pages/BusinessProcessViewPage.tsx @@ -199,6 +199,21 @@ const buildProcessPayload = (form: ProcessFormState): BusinessProcessUpsertPaylo process_health_state: toNullable(form.process_health_state), }); +const changedProcessPayload = ( + current: ProcessFormState, + next: ProcessFormState, +): BusinessProcessUpsertPayload => { + const currentPayload = buildProcessPayload(current); + const nextPayload = buildProcessPayload(next); + const changed: BusinessProcessUpsertPayload = {}; + (Object.keys(nextPayload) as Array).forEach(key => { + if (nextPayload[key] !== currentPayload[key]) { + (changed as Record)[key] = nextPayload[key] ?? null; + } + }); + return changed; +}; + const labelFromOptions = (value: string, options: Option[]): string => { if (!value) return 'N/A'; const found = options.find(o => o.value === value); @@ -495,9 +510,15 @@ const BusinessProcessViewPage: React.FC = () => { setInlineSaving(inlineEdit.field); setActionError(null); try { + const changedPayload = changedProcessPayload(formFromProcess(process), nextForm); + if (Object.keys(changedPayload).length === 0) { + setInlineEdit(null); + setAttemptedSave(false); + return; + } const updated = await businessRelationsApi.updateProcess( process.business_process_id, - buildProcessPayload(nextForm), + changedPayload, ); setProcess(updated); setForm(formFromProcess(updated)); @@ -648,7 +669,14 @@ const BusinessProcessViewPage: React.FC = () => { return; } if (!process) return; - const updated = await businessRelationsApi.updateProcess(process.business_process_id, payload); + const changedPayload = changedProcessPayload(formFromProcess(process), form); + if (Object.keys(changedPayload).length === 0) { + setAttemptedSave(false); + setInlineEdit(null); + setEditing(false); + return; + } + const updated = await businessRelationsApi.updateProcess(process.business_process_id, changedPayload); setProcess(updated); setForm(formFromProcess(updated)); setAttemptedSave(false); diff --git a/tavro_app/src/pages/HomePage.tsx b/tavro_app/src/pages/HomePage.tsx index 539fde5..549385d 100644 --- a/tavro_app/src/pages/HomePage.tsx +++ b/tavro_app/src/pages/HomePage.tsx @@ -1,10 +1,17 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ClipboardList, Map, Layers, Cpu, Zap, Activity, ArrowRight, TrendingUp, AlertCircle, Bot, X, ArrowUpRight, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useChatContext } from '../context/ChatContext'; +import { useBlueprint } from '../context/BlueprintContext'; +import { + insightsApi, + type HomeAttentionItem, + type InsightsTotals, +} from '../services/insightsApi'; +import { portalActivity, type PortalActivityItem } from '../services/portalActivity'; import travoLogo from '../assets/travo_logo.png'; const STAGES = [ @@ -16,38 +23,34 @@ const STAGES = [ { id: 'govern', label: 'Govern', Icon: Activity, route: '/compliance' }, ]; -const STATS = [ +const STAT_CARDS = [ { + key: 'sparkIdeas', label: 'Spark Ideas', - value: 84, - sub: '+12 this week', subColor: 'text-emerald-600 dark:text-emerald-400', Icon: Zap, iconColor: 'text-violet-600 dark:text-violet-400', iconBg: 'bg-violet-50 dark:bg-violet-900/20', }, { + key: 'useCases', label: 'AI Use Cases', - value: 52, - sub: '8 in progress', subColor: 'text-slate-500 dark:text-slate-400', Icon: ClipboardList, iconColor: 'text-blue-600 dark:text-blue-400', iconBg: 'bg-blue-50 dark:bg-blue-900/20', }, { + key: 'agents', label: 'Active Agents', - value: 84, - sub: '6 live in prod', subColor: 'text-emerald-600 dark:text-emerald-400', Icon: Bot, iconColor: 'text-purple-600 dark:text-purple-400', iconBg: 'bg-purple-50 dark:bg-purple-900/20', }, { + key: 'issues', label: 'Open Issues', - value: 3, - sub: '2 need review', subColor: 'text-rose-600 dark:text-rose-400', Icon: AlertCircle, iconColor: 'text-rose-600 dark:text-rose-400', @@ -55,57 +58,121 @@ const STATS = [ }, ]; -const RECENT_ACTIVITY = [ - { id: 1, text: 'Invoice automation moved to Design stage', time: '2h ago', dot: 'bg-violet-500' }, - { id: 2, text: '3 new Spark ideas generated', time: '5h ago', dot: 'bg-emerald-500' }, - { id: 3, text: 'HR onboarding agent - risk flag raised', time: 'Yesterday', dot: 'bg-amber-500' }, - { id: 4, text: 'Procurement agent deployed to production', time: '2 days ago', dot: 'bg-emerald-500' }, -]; +const EMPTY_TOTALS: InsightsTotals = { + sparkIdeas: 0, + sparkIdeasThisWeek: 0, + totalAgents: 0, + liveAgents: 0, + totalUseCases: 0, + useCasesInProgress: 0, + criticalCount: 0, + highRiskCount: 0, + hitlOpen: 0, + openIssues: 0, + needReview: 0, +}; -const ATTENTION_ITEMS = [ - { - id: 1, - badge: 'Risk', - badgeClass: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', - text: 'HR onboarding agent - autonomy level review required', - action: 'Review', - route: '/catalog', - }, - { - id: 2, - badge: 'Approval', - badgeClass: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', - text: 'Invoice automation - stakeholder sign-off pending', - action: 'Review', - route: '/use-cases', - }, - { - id: 3, - badge: 'Issue', - badgeClass: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400', - text: 'Procurement agent - behavioral drift detected', - action: 'Review', - route: '/catalog', - }, - { - id: 4, - badge: 'Incomplete', - badgeClass: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400', - text: 'Blueprint - financials section not yet populated', - action: 'Complete', - route: '/blueprint', - }, -]; +const plural = (count: number, singular: string, pluralLabel = `${singular}s`) => + `${count} ${count === 1 ? singular : pluralLabel}`; + +const dotClass = (dot: string) => { + const map: Record = { + violet: 'bg-violet-500', + emerald: 'bg-emerald-500', + amber: 'bg-amber-500', + }; + return map[dot] ?? 'bg-slate-400'; +}; + +const badgeClass = (badge: string) => { + const map: Record = { + Risk: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + Approval: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', + Issue: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400', + Incomplete: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400', + }; + return map[badge] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'; +}; const HomePage: React.FC = () => { const navigate = useNavigate(); const { setViewContext } = useChatContext(); + const { activeCompany } = useBlueprint(); const [showDeployMessage, setShowDeployMessage] = useState(false); + const [totals, setTotals] = useState(EMPTY_TOTALS); + const [recentActivity, setRecentActivity] = useState(() => portalActivity.list(4)); + const [attentionItems, setAttentionItems] = useState([]); + const [statsLoading, setStatsLoading] = useState(true); useEffect(() => { setViewContext('home'); }, [setViewContext]); + const loadStats = useCallback(async () => { + setStatsLoading(true); + try { + const companyId = activeCompany?.id ?? localStorage.getItem('tavro_active_company_id') ?? undefined; + const summary = await insightsApi.getSummary(companyId); + setTotals({ ...EMPTY_TOTALS, ...summary.totals }); + setAttentionItems(summary.homeAttentionItems ?? []); + } catch (err) { + console.warn('[HomePage] Failed to load dashboard metrics:', err); + setTotals(EMPTY_TOTALS); + setAttentionItems([]); + } finally { + setStatsLoading(false); + } + }, [activeCompany?.id]); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + useEffect(() => { + const syncActivity = () => setRecentActivity(portalActivity.list(4)); + syncActivity(); + window.addEventListener('tavro:portal-activity-changed', syncActivity); + const timer = window.setInterval(syncActivity, 60_000); + return () => { + window.removeEventListener('tavro:portal-activity-changed', syncActivity); + window.clearInterval(timer); + }; + }, []); + + const stats = useMemo(() => { + const openIssues = totals.openIssues ?? totals.hitlOpen; + return STAT_CARDS.map(card => { + if (card.key === 'sparkIdeas') { + return { + ...card, + value: totals.sparkIdeas, + sub: totals.sparkIdeasThisWeek > 0 + ? `+${totals.sparkIdeasThisWeek} this week` + : '0 this week', + }; + } + if (card.key === 'useCases') { + return { + ...card, + value: totals.totalUseCases, + sub: plural(totals.useCasesInProgress, 'in progress', 'in progress'), + }; + } + if (card.key === 'agents') { + return { + ...card, + value: totals.totalAgents, + sub: `${totals.liveAgents} live in prod`, + }; + } + return { + ...card, + value: openIssues, + sub: `${totals.needReview} need review`, + }; + }); + }, [totals]); + const handleStageClick = (id: string, route: string | null) => { if (id === 'deploy') { setShowDeployMessage(true); @@ -136,7 +203,7 @@ const HomePage: React.FC = () => { {/* ── Stats ── */}
- {STATS.map(({ label, value, sub, subColor, Icon, iconColor, iconBg }) => ( + {stats.map(({ label, value, sub, subColor, Icon, iconColor, iconBg }) => (
{
-

{value}

+

+ {statsLoading ? '...' : value} +

{sub}

))} @@ -206,13 +275,19 @@ const HomePage: React.FC = () => { Recent Activity
- {RECENT_ACTIVITY.map(({ id, text, time, dot }) => ( -
- -

{text}

- {time} -
- ))} + {recentActivity.length > 0 ? ( + recentActivity.map(({ id, text, timestamp, dot }) => ( +
+ +

{text}

+ + {portalActivity.formatTime(timestamp)} + +
+ )) + ) : ( +

No recent activity yet

+ )}
@@ -222,20 +297,26 @@ const HomePage: React.FC = () => { Needs Your Attention
- {ATTENTION_ITEMS.map(({ id, badge, badgeClass, text, action, route }) => ( -
- - {badge} - -

{text}

- -
- ))} + {statsLoading && attentionItems.length === 0 ? ( +

Loading attention items...

+ ) : attentionItems.length > 0 ? ( + attentionItems.map(({ id, badge, text, action, route }) => ( +
+ + {badge} + +

{text}

+ +
+ )) + ) : ( +

Nothing needs attention right now

+ )}
diff --git a/tavro_app/src/pages/IntegrationViewPage.tsx b/tavro_app/src/pages/IntegrationViewPage.tsx index dbeba2f..b8baca1 100644 --- a/tavro_app/src/pages/IntegrationViewPage.tsx +++ b/tavro_app/src/pages/IntegrationViewPage.tsx @@ -157,6 +157,21 @@ const buildIntegrationPayload = (form: IntegrationFormState): IntegrationUpsertP parent_application_id: toNullable(form.parent_application_id), }); +const changedIntegrationPayload = ( + current: IntegrationFormState, + next: IntegrationFormState, +): IntegrationUpsertPayload => { + const currentPayload = buildIntegrationPayload(current); + const nextPayload = buildIntegrationPayload(next); + const changed: IntegrationUpsertPayload = {}; + (Object.keys(nextPayload) as Array).forEach(key => { + if (nextPayload[key] !== currentPayload[key]) { + (changed as Record)[key] = nextPayload[key] ?? null; + } + }); + return changed; +}; + const HintLabel: React.FC<{ label: string; hint?: string; required?: boolean }> = ({ label, hint, required }) => (