mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
I mean, it's at a good place rn...
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Task, TaskStatus } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AlertTriangle, Clock, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ActiveBlockersPanelProps {
|
||||
tasks: Task[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatDuration(date: string): string {
|
||||
const start = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) return "< 1h";
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
return `${diffDays}d`;
|
||||
}
|
||||
|
||||
export function ActiveBlockersPanel({ tasks, isLoading }: ActiveBlockersPanelProps) {
|
||||
// Filter blocked tasks and sort by how long they've been blocked
|
||||
const blockedTasks = (tasks ?? [])
|
||||
.filter((t) => t.status === TaskStatus.BLOCKED)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.updated_at ? new Date(a.updated_at).getTime() : 0;
|
||||
const bTime = b.updated_at ? new Date(b.updated_at).getTime() : 0;
|
||||
return aTime - bTime; // Oldest first
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Active Blockers
|
||||
</CardTitle>
|
||||
{blockedTasks.length > 0 && (
|
||||
<Badge variant="destructive">{blockedTasks.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-16" />
|
||||
</div>
|
||||
) : blockedTasks.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
No blocked tasks
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{blockedTasks.map((task) => (
|
||||
<Link key={task.id} href={"/tasks/" + task.id}>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 transition-colors">
|
||||
<span className="text-lg">\uD83D\uDD34</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
Task #{task.id.slice(0, 8)}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{task.team.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Blocked for {formatDuration(task.updated_at ?? task.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link href="/tasks?status=blocked">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
View All Blocked
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle, Play, Pause, AlertTriangle, User, Clock } from "lucide-react";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
action: string;
|
||||
task_id?: string;
|
||||
task_title?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity;
|
||||
}
|
||||
|
||||
const actionIcons: Record<string, React.ReactNode> = {
|
||||
completed: <CheckCircle className="h-4 w-4 text-green-500" />,
|
||||
started: <Play className="h-4 w-4 text-blue-500" />,
|
||||
paused: <Pause className="h-4 w-4 text-yellow-500" />,
|
||||
blocked: <AlertTriangle className="h-4 w-4 text-red-500" />,
|
||||
claimed: <User className="h-4 w-4 text-purple-500" />,
|
||||
passed_qa: <CheckCircle className="h-4 w-4 text-green-500" />,
|
||||
failed_qa: <AlertTriangle className="h-4 w-4 text-red-500" />,
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
completed: "completed",
|
||||
started: "started",
|
||||
paused: "paused",
|
||||
blocked: "blocked on",
|
||||
claimed: "claimed",
|
||||
passed_qa: "passed QA on",
|
||||
failed_qa: "failed QA on",
|
||||
};
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function ActivityItem({ activity }: ActivityItemProps) {
|
||||
const action = activity.action || "unknown";
|
||||
const icon = actionIcons[action] || <Clock className="h-4 w-4" />;
|
||||
const label = actionLabels[action] || action;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<div className="mt-0.5">{icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{getAgentDisplayName(activity.agent_id)}</span>
|
||||
{" "}
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{activity.task_title && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="font-medium">{activity.task_title}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activity.timestamp ? formatTime(activity.timestamp) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { AuditorFlag, FlagSeverity } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Shield, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface AuditorAlertsPanelProps {
|
||||
alerts: AuditorFlag[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const severityColors: Record<FlagSeverity, string> = {
|
||||
[FlagSeverity.INFO]: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
[FlagSeverity.WARNING]: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
[FlagSeverity.URGENT]: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
const severityEmoji: Record<FlagSeverity, string> = {
|
||||
[FlagSeverity.INFO]: "\uD83D\uDFE2",
|
||||
[FlagSeverity.WARNING]: "\uD83D\uDFE1",
|
||||
[FlagSeverity.URGENT]: "\uD83D\uDD34",
|
||||
};
|
||||
|
||||
export function AuditorAlertsPanel({ alerts, isLoading }: AuditorAlertsPanelProps) {
|
||||
// Filter to show only unresolved, sorted by severity
|
||||
const unresolvedAlerts = (alerts ?? [])
|
||||
.filter((a) => !a.resolved_at)
|
||||
.sort((a, b) => {
|
||||
const order: Record<FlagSeverity, number> = {
|
||||
[FlagSeverity.URGENT]: 0,
|
||||
[FlagSeverity.WARNING]: 1,
|
||||
[FlagSeverity.INFO]: 2,
|
||||
};
|
||||
return order[a.severity] - order[b.severity];
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Auditor Alerts
|
||||
</CardTitle>
|
||||
{unresolvedAlerts.length > 0 && (
|
||||
<Badge variant="destructive">{unresolvedAlerts.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
) : unresolvedAlerts.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
<Shield className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
No active alerts
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{unresolvedAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border bg-muted/30"
|
||||
>
|
||||
<span className="text-lg">{severityEmoji[alert.severity]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">{alert.title}</span>
|
||||
<Badge className={severityColors[alert.severity] + " text-xs"}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{alert.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link href="/auditor">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
View All Flags
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { tasksApi } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle2, XCircle, Clock, FileText, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Task } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CeoApprovalQueueProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [actionType, setActionType] = useState<"approve" | "reject" | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Fetch tasks awaiting CEO approval
|
||||
const { data: tasks, isLoading } = useQuery({
|
||||
queryKey: ["tasks", "awaiting-ceo-approval"],
|
||||
queryFn: () => tasksApi.getAwaitingCeoApproval(),
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Approve mutation
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ taskId, notes }: { taskId: string; notes?: string }) =>
|
||||
tasksApi.ceoApprove(taskId, notes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
toast.success("Task approved and completed");
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to approve: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Reject mutation
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ taskId, notes }: { taskId: string; notes: string }) =>
|
||||
tasksApi.ceoReject(taskId, notes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
toast.success("Task rejected and sent back for revision");
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to reject: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
},
|
||||
});
|
||||
|
||||
const openDialog = (task: Task, action: "approve" | "reject") => {
|
||||
setSelectedTask(task);
|
||||
setActionType(action);
|
||||
setNotes("");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setSelectedTask(null);
|
||||
setActionType(null);
|
||||
setNotes("");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedTask) return;
|
||||
|
||||
if (actionType === "approve") {
|
||||
approveMutation.mutate({ taskId: selectedTask.id, notes: notes || undefined });
|
||||
} else if (actionType === "reject") {
|
||||
if (!notes.trim()) {
|
||||
toast.error("Rejection reason is required");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({ taskId: selectedTask.id, notes });
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: number) => {
|
||||
const variants: Record<number, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
0: { label: "P0", variant: "destructive" },
|
||||
1: { label: "P1", variant: "destructive" },
|
||||
2: { label: "P2", variant: "secondary" },
|
||||
3: { label: "P3", variant: "outline" },
|
||||
};
|
||||
const { label, variant } = variants[priority] || { label: `P${priority}`, variant: "outline" as const };
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
CEO Approval Queue
|
||||
</CardTitle>
|
||||
<CardDescription>Tasks awaiting your approval</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingTasks = tasks || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
CEO Approval Queue
|
||||
{pendingTasks.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{pendingTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>Tasks escalated for your final approval</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pendingTasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CheckCircle2 className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No tasks awaiting approval</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pendingTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getPriorityBadge(task.priority)}
|
||||
<Badge variant="outline">{task.team}</Badge>
|
||||
</div>
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
{task.quick_context && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.quick_context}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
||||
<Link href={`/tasks/${task.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => openDialog(task, "reject")}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => openDialog(task, "approve")}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog open={!!selectedTask && !!actionType} onOpenChange={() => closeDialog()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{actionType === "approve" ? "Approve Task" : "Reject Task"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{actionType === "approve"
|
||||
? "This will complete the task and notify the team."
|
||||
: "This will send the task back for revision."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTask && (
|
||||
<div className="py-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getPriorityBadge(selectedTask.priority)}
|
||||
<Badge variant="outline">{selectedTask.team}</Badge>
|
||||
</div>
|
||||
<p className="font-medium">{selectedTask.title}</p>
|
||||
{selectedTask.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-3">
|
||||
{selectedTask.description}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/tasks/${selectedTask.id}`}
|
||||
target="_blank"
|
||||
className="text-sm text-primary flex items-center gap-1 mt-2 hover:underline"
|
||||
>
|
||||
View full details <ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">
|
||||
{actionType === "approve" ? "Notes (optional)" : "Reason for rejection (required)"}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder={
|
||||
actionType === "approve"
|
||||
? "Add any notes about this approval..."
|
||||
: "Explain what needs to be fixed..."
|
||||
}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
className={actionType === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
variant={actionType === "reject" ? "destructive" : "default"}
|
||||
>
|
||||
{approveMutation.isPending || rejectMutation.isPending
|
||||
? "Processing..."
|
||||
: actionType === "approve"
|
||||
? "Approve & Complete"
|
||||
: "Reject & Request Revision"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useCeoOverview, useAuditorFlags, useRecentActivity } from "@/hooks/use-dashboard";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import { TeamHealthCards } from "./team-health-cards";
|
||||
import { KeyMetricsPanel } from "./key-metrics-panel";
|
||||
import { AuditorAlertsPanel } from "./auditor-alerts-panel";
|
||||
import { ActiveBlockersPanel } from "./active-blockers-panel";
|
||||
import { RecentActivityFeed } from "./recent-activity-feed";
|
||||
import { QuickActionsBar } from "./quick-actions-bar";
|
||||
import { CeoApprovalQueue } from "./ceo-approval-queue";
|
||||
import type { Activity } from "./activity-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function CommandCenter() {
|
||||
const { data: overview, isLoading: loadingOverview, refetch: refetchOverview } = useCeoOverview();
|
||||
const { data: flags, isLoading: loadingFlags } = useAuditorFlags({ resolved: false });
|
||||
const { data: tasks, isLoading: loadingTasks } = useTasks();
|
||||
const { data: activity, isLoading: loadingActivity } = useRecentActivity(24);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchOverview();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">RoboCo Command Center</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Complete visibility into all operations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Health */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Team Health</h2>
|
||||
<TeamHealthCards
|
||||
teams={overview?.health_status}
|
||||
isLoading={loadingOverview}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* CEO Approval Queue - Your primary action item */}
|
||||
<section>
|
||||
<CeoApprovalQueue />
|
||||
</section>
|
||||
|
||||
{/* Metrics and Alerts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<KeyMetricsPanel
|
||||
metrics={overview?.key_metrics}
|
||||
isLoading={loadingOverview}
|
||||
/>
|
||||
<AuditorAlertsPanel alerts={flags} isLoading={loadingFlags} />
|
||||
</div>
|
||||
|
||||
{/* Blockers and Activity Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ActiveBlockersPanel tasks={tasks} isLoading={loadingTasks} />
|
||||
<RecentActivityFeed
|
||||
activities={activity as Activity[] | undefined}
|
||||
isLoading={loadingActivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<section className="pt-4 border-t">
|
||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||
<QuickActionsBar />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
interface HealthIndicatorProps {
|
||||
status: "ok" | "slow" | "critical";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const statusEmoji: Record<string, string> = {
|
||||
ok: "\uD83D\uDFE2",
|
||||
slow: "\uD83D\uDFE1",
|
||||
critical: "\uD83D\uDD34",
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
ok: "OK",
|
||||
slow: "SLOW",
|
||||
critical: "CRITICAL",
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
ok: "text-green-600",
|
||||
slow: "text-yellow-600",
|
||||
critical: "text-red-600",
|
||||
};
|
||||
|
||||
export function HealthIndicator({ status, size = "md" }: HealthIndicatorProps) {
|
||||
const sizeClasses = {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${sizeClasses[size]} ${statusColor[status]} font-medium`}>
|
||||
{statusEmoji[status]} {statusLabel[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { CommandCenter } from "./command-center";
|
||||
export { TeamHealthCards } from "./team-health-cards";
|
||||
export { TeamHealthCard } from "./team-health-card";
|
||||
export { KeyMetricsPanel } from "./key-metrics-panel";
|
||||
export { AuditorAlertsPanel } from "./auditor-alerts-panel";
|
||||
export { ActiveBlockersPanel } from "./active-blockers-panel";
|
||||
export { RecentActivityFeed } from "./recent-activity-feed";
|
||||
export { ActivityItem } from "./activity-item";
|
||||
export { QuickActionsBar } from "./quick-actions-bar";
|
||||
export { HealthIndicator } from "./health-indicator";
|
||||
export { CeoApprovalQueue } from "./ceo-approval-queue";
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TrendingUp, Clock, CheckCircle, Users, BarChart3 } from "lucide-react";
|
||||
|
||||
interface KeyMetricsProps {
|
||||
metrics: Record<string, unknown> | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface MetricItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
const METRIC_CONFIG: MetricItem[] = [
|
||||
{
|
||||
key: "velocity_24h",
|
||||
label: "Velocity (24h)",
|
||||
icon: <TrendingUp className="h-4 w-4" />,
|
||||
format: (v) => `${v} tasks`,
|
||||
},
|
||||
{
|
||||
key: "velocity_7d",
|
||||
label: "Velocity (7d)",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
format: (v) => `${v} tasks`,
|
||||
},
|
||||
{
|
||||
key: "completion_rate",
|
||||
label: "Completion Rate",
|
||||
icon: <CheckCircle className="h-4 w-4" />,
|
||||
format: (v) => `${Math.round(v * 100)}%`,
|
||||
},
|
||||
{
|
||||
key: "avg_time_to_done",
|
||||
label: "Avg. Time to Done",
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
format: (v) => `${(typeof v === "number" ? v : 0).toFixed(1)}h`,
|
||||
},
|
||||
{
|
||||
key: "active_agents",
|
||||
label: "Active Agents",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
format: (v) => `${v}`,
|
||||
},
|
||||
];
|
||||
|
||||
export function KeyMetricsPanel({ metrics, isLoading }: KeyMetricsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Key Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{METRIC_CONFIG.map((m) => (
|
||||
<Skeleton key={m.key} className="h-6" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{METRIC_CONFIG.map((m) => {
|
||||
const rawValue = metrics?.[m.key];
|
||||
const value = typeof rawValue === "number" ? rawValue : null;
|
||||
return (
|
||||
<div key={m.key} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{m.icon}
|
||||
{m.label}
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{value != null
|
||||
? m.format
|
||||
? m.format(value)
|
||||
: value
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreateTaskDialog } from "@/components/tasks/create-task-dialog";
|
||||
import { Users, Megaphone, BookOpen, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function QuickActionsBar() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CreateTaskDialog />
|
||||
|
||||
<Link href="/agents">
|
||||
<Button variant="outline">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Spawn Agent
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/communications">
|
||||
<Button variant="outline">
|
||||
<Megaphone className="h-4 w-4 mr-2" />
|
||||
Broadcast Message
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/journals">
|
||||
<Button variant="outline">
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
View Journals
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/auditor">
|
||||
<Button variant="outline">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Auditor Report
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Activity as ActivityIcon, ArrowRight } from "lucide-react";
|
||||
import { ActivityItem, Activity } from "./activity-item";
|
||||
import Link from "next/link";
|
||||
|
||||
interface RecentActivityFeedProps {
|
||||
activities: Activity[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function RecentActivityFeed({ activities, isLoading }: RecentActivityFeedProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ActivityIcon className="h-5 w-5" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
) : !activities || activities.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
<ActivityIcon className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
No recent activity
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[280px] pr-4">
|
||||
<div className="divide-y">
|
||||
{activities.slice(0, 10).map((activity) => (
|
||||
<ActivityItem key={activity.id} activity={activity} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link href="/notifications">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
View Full Activity
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { TeamHealth } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HealthIndicator } from "./health-indicator";
|
||||
import { Users, AlertTriangle, TrendingUp } from "lucide-react";
|
||||
|
||||
interface TeamHealthCardProps {
|
||||
health: TeamHealth;
|
||||
}
|
||||
|
||||
export function TeamHealthCard({ health }: TeamHealthCardProps) {
|
||||
const teamName = health.team.replace(/_/g, " ");
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg capitalize">{teamName}</CardTitle>
|
||||
<HealthIndicator status={health.status} size="sm" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Active Tasks */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
Active
|
||||
</div>
|
||||
<span className="font-medium">{health.active_tasks}</span>
|
||||
</div>
|
||||
|
||||
{/* Blocked Tasks */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Blocked
|
||||
</div>
|
||||
<span className={`font-medium ${health.blocked_tasks > 0 ? "text-red-600" : ""}`}>
|
||||
{health.blocked_tasks}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Completed This Week */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Completed (7d)
|
||||
</div>
|
||||
<span className="font-medium">{health.completed_this_week}</span>
|
||||
</div>
|
||||
|
||||
{/* Blocked Ratio */}
|
||||
{health.blocked_ratio > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<Badge
|
||||
variant={health.blocked_ratio > 0.3 ? "destructive" : health.blocked_ratio > 0.1 ? "secondary" : "outline"}
|
||||
>
|
||||
{Math.round(health.blocked_ratio * 100)}% blocked
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { TeamHealth } from "@/types";
|
||||
import { TeamHealthCard } from "./team-health-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface TeamHealthCardsProps {
|
||||
teams: TeamHealth[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function TeamHealthCards({ teams, isLoading }: TeamHealthCardsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No team health data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{teams.map((health) => (
|
||||
<TeamHealthCard key={health.team} health={health} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user