Create or select a company to view the dashboard.
;
+ return (
+
+
-
Dashboard
-
{selectedCompany?.name}
+
Dashboard
+ {selectedCompany && (
+
{selectedCompany.name}
+ )}
- {loading &&
Loading...
}
- {error &&
{error.message}
}
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
{data && (
-
-
-
- Agents
- Running: {data.agents.running}
- Paused: {data.agents.paused}
- Error: {data.agents.error}
-
-
-
-
- Tasks
- Open: {data.tasks.open}
- In Progress: {data.tasks.inProgress}
- Blocked: {data.tasks.blocked}
-
-
-
-
- Costs
-
- {formatCents(data.costs.monthSpendCents)} / {formatCents(data.costs.monthBudgetCents)}
-
- Utilization: {data.costs.monthUtilizationPercent}%
-
-
-
-
- Governance
- Pending approvals: {data.pendingApprovals}
- Stale tasks: {data.staleTasks}
-
-
-
+ <>
+
+
+
+
+
+
+
+ {activity && activity.length > 0 && (
+
+
+ Recent Activity
+
+
+ {activity.slice(0, 10).map((event) => (
+
+
+ {event.action}
+
+ {event.entityType}
+
+
+
+ {timeAgo(event.createdAt)}
+
+
+ ))}
+
+
+ )}
+ >
)}
);
diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx
new file mode 100644
index 00000000..2ec56ff3
--- /dev/null
+++ b/ui/src/pages/GoalDetail.tsx
@@ -0,0 +1,113 @@
+import { useCallback, useEffect } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { goalsApi } from "../api/goals";
+import { projectsApi } from "../api/projects";
+import { useApi } from "../hooks/useApi";
+import { usePanel } from "../context/PanelContext";
+import { useCompany } from "../context/CompanyContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { GoalProperties } from "../components/GoalProperties";
+import { GoalTree } from "../components/GoalTree";
+import { StatusBadge } from "../components/StatusBadge";
+import { EntityRow } from "../components/EntityRow";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { Goal, Project } from "@paperclip/shared";
+
+export function GoalDetail() {
+ const { goalId } = useParams<{ goalId: string }>();
+ const { selectedCompanyId } = useCompany();
+ const { openPanel, closePanel } = usePanel();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const navigate = useNavigate();
+
+ const goalFetcher = useCallback(() => {
+ if (!goalId) return Promise.reject(new Error("No goal ID"));
+ return goalsApi.get(goalId);
+ }, [goalId]);
+
+ const allGoalsFetcher = useCallback(() => {
+ if (!selectedCompanyId) return Promise.resolve([] as Goal[]);
+ return goalsApi.list(selectedCompanyId);
+ }, [selectedCompanyId]);
+
+ const projectsFetcher = useCallback(() => {
+ if (!selectedCompanyId) return Promise.resolve([] as Project[]);
+ return projectsApi.list(selectedCompanyId);
+ }, [selectedCompanyId]);
+
+ const { data: goal, loading, error } = useApi(goalFetcher);
+ const { data: allGoals } = useApi(allGoalsFetcher);
+ const { data: allProjects } = useApi(projectsFetcher);
+
+ const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
+ const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: "Goals", href: "/goals" },
+ { label: goal?.title ?? goalId ?? "Goal" },
+ ]);
+ }, [setBreadcrumbs, goal, goalId]);
+
+ useEffect(() => {
+ if (goal) {
+ openPanel(
);
+ }
+ return () => closePanel();
+ }, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (loading) return
Loading...
;
+ if (error) return
{error.message}
;
+ if (!goal) return null;
+
+ return (
+
+
+
+ {goal.level}
+
+
+
{goal.title}
+ {goal.description && (
+
{goal.description}
+ )}
+
+
+
+
+ Sub-Goals ({childGoals.length})
+ Projects ({linkedProjects.length})
+
+
+
+ {childGoals.length === 0 ? (
+ No sub-goals.
+ ) : (
+ navigate(`/goals/${g.id}`)}
+ />
+ )}
+
+
+
+ {linkedProjects.length === 0 ? (
+ No linked projects.
+ ) : (
+
+ {linkedProjects.map((project) => (
+ navigate(`/projects/${project.id}`)}
+ trailing={}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx
index ceda8115..17044c20 100644
--- a/ui/src/pages/Goals.tsx
+++ b/ui/src/pages/Goals.tsx
@@ -1,12 +1,21 @@
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
import { goalsApi } from "../api/goals";
import { useApi } from "../hooks/useApi";
-import { StatusBadge } from "../components/StatusBadge";
import { useCompany } from "../context/CompanyContext";
-import { Card, CardContent } from "@/components/ui/card";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { GoalTree } from "../components/GoalTree";
+import { EmptyState } from "../components/EmptyState";
+import { Target } from "lucide-react";
export function Goals() {
const { selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "Goals" }]);
+ }, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
@@ -16,32 +25,22 @@ export function Goals() {
const { data: goals, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
- return
Select a company first.
;
+ return
;
}
return (
-
-
Goals
- {loading &&
Loading...
}
- {error &&
{error.message}
}
- {goals && goals.length === 0 &&
No goals yet.
}
+
+
Goals
+
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
+
+ {goals && goals.length === 0 && (
+
+ )}
+
{goals && goals.length > 0 && (
-
- {goals.map((goal) => (
-
-
-
-
-
{goal.title}
- {goal.description &&
{goal.description}
}
-
Level: {goal.level}
-
-
-
-
-
- ))}
-
+
navigate(`/goals/${goal.id}`)} />
)}
);
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
new file mode 100644
index 00000000..74721dda
--- /dev/null
+++ b/ui/src/pages/Inbox.tsx
@@ -0,0 +1,132 @@
+import { useCallback, useEffect, useState } from "react";
+import { approvalsApi } from "../api/approvals";
+import { dashboardApi } from "../api/dashboard";
+import { useCompany } from "../context/CompanyContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { useApi } from "../hooks/useApi";
+import { StatusBadge } from "../components/StatusBadge";
+import { EmptyState } from "../components/EmptyState";
+import { timeAgo } from "../lib/timeAgo";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Inbox as InboxIcon } from "lucide-react";
+
+export function Inbox() {
+ const { selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const [actionError, setActionError] = useState
(null);
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "Inbox" }]);
+ }, [setBreadcrumbs]);
+
+ const approvalsFetcher = useCallback(() => {
+ if (!selectedCompanyId) return Promise.resolve([]);
+ return approvalsApi.list(selectedCompanyId, "pending");
+ }, [selectedCompanyId]);
+
+ const dashboardFetcher = useCallback(() => {
+ if (!selectedCompanyId) return Promise.resolve(null);
+ return dashboardApi.summary(selectedCompanyId);
+ }, [selectedCompanyId]);
+
+ const { data: approvals, loading, error, reload } = useApi(approvalsFetcher);
+ const { data: dashboard } = useApi(dashboardFetcher);
+
+ async function approve(id: string) {
+ setActionError(null);
+ try {
+ await approvalsApi.approve(id);
+ reload();
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : "Failed to approve");
+ }
+ }
+
+ async function reject(id: string) {
+ setActionError(null);
+ try {
+ await approvalsApi.reject(id);
+ reload();
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : "Failed to reject");
+ }
+ }
+
+ if (!selectedCompanyId) {
+ return ;
+ }
+
+ const hasContent = (approvals && approvals.length > 0) || (dashboard && (dashboard.staleTasks > 0));
+
+ return (
+
+
Inbox
+
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
+ {actionError &&
{actionError}
}
+
+ {!loading && !hasContent && (
+
+ )}
+
+ {approvals && approvals.length > 0 && (
+
+
+ Pending Approvals ({approvals.length})
+
+
+ {approvals.map((approval) => (
+
+
+
+ {approval.type.replace(/_/g, " ")}
+
+ {timeAgo(approval.createdAt)}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {dashboard && dashboard.staleTasks > 0 && (
+ <>
+
+
+
+ Stale Work
+
+
+
+ {dashboard.staleTasks} tasks have gone stale
+ and may need attention.
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
new file mode 100644
index 00000000..88c7bbc6
--- /dev/null
+++ b/ui/src/pages/IssueDetail.tsx
@@ -0,0 +1,105 @@
+import { useCallback, useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { issuesApi } from "../api/issues";
+import { useApi } from "../hooks/useApi";
+import { usePanel } from "../context/PanelContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { InlineEditor } from "../components/InlineEditor";
+import { CommentThread } from "../components/CommentThread";
+import { IssueProperties } from "../components/IssueProperties";
+import { StatusIcon } from "../components/StatusIcon";
+import { PriorityIcon } from "../components/PriorityIcon";
+import type { IssueComment } from "@paperclip/shared";
+import { Separator } from "@/components/ui/separator";
+
+export function IssueDetail() {
+ const { issueId } = useParams<{ issueId: string }>();
+ const { openPanel, closePanel } = usePanel();
+ const { setBreadcrumbs } = useBreadcrumbs();
+
+ const issueFetcher = useCallback(() => {
+ if (!issueId) return Promise.reject(new Error("No issue ID"));
+ return issuesApi.get(issueId);
+ }, [issueId]);
+
+ const commentsFetcher = useCallback(() => {
+ if (!issueId) return Promise.resolve([] as IssueComment[]);
+ return issuesApi.listComments(issueId);
+ }, [issueId]);
+
+ const { data: issue, loading, error, reload: reloadIssue } = useApi(issueFetcher);
+ const { data: comments, reload: reloadComments } = useApi(commentsFetcher);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: "Issues", href: "/tasks" },
+ { label: issue?.title ?? issueId ?? "Issue" },
+ ]);
+ }, [setBreadcrumbs, issue, issueId]);
+
+ async function handleUpdate(data: Record) {
+ if (!issueId) return;
+ await issuesApi.update(issueId, data);
+ reloadIssue();
+ }
+
+ async function handleAddComment(body: string) {
+ if (!issueId) return;
+ await issuesApi.addComment(issueId, body);
+ reloadComments();
+ }
+
+ useEffect(() => {
+ if (issue) {
+ openPanel(
+
+ );
+ }
+ return () => closePanel();
+ }, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (loading) return Loading...
;
+ if (error) return {error.message}
;
+ if (!issue) return null;
+
+ return (
+
+
+
+
handleUpdate({ status })}
+ />
+ handleUpdate({ priority })}
+ />
+ {issue.id.slice(0, 8)}
+
+
+
handleUpdate({ title })}
+ as="h2"
+ className="text-xl font-bold"
+ />
+
+ handleUpdate({ description })}
+ as="p"
+ className="text-sm text-muted-foreground"
+ placeholder="Add a description..."
+ multiline
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index ce779a49..4856974e 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -1,67 +1,149 @@
-import { useCallback } from "react";
+import { useCallback, useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
-import { StatusBadge } from "../components/StatusBadge";
-import { cn } from "../lib/utils";
import { useCompany } from "../context/CompanyContext";
-import { Card, CardContent } from "@/components/ui/card";
+import { useDialog } from "../context/DialogContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { groupBy } from "../lib/groupBy";
+import { StatusIcon } from "../components/StatusIcon";
+import { PriorityIcon } from "../components/PriorityIcon";
+import { EntityRow } from "../components/EntityRow";
+import { EmptyState } from "../components/EmptyState";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { CircleDot, Plus } from "lucide-react";
+import { formatDate } from "../lib/utils";
+import type { Issue } from "@paperclip/shared";
-const priorityColors: Record = {
- critical: "text-red-300 bg-red-900/50",
- high: "text-orange-300 bg-orange-900/50",
- medium: "text-yellow-300 bg-yellow-900/50",
- low: "text-neutral-400 bg-neutral-800",
-};
+const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
+
+function statusLabel(status: string): string {
+ return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+type TabFilter = "all" | "active" | "backlog" | "done";
+
+function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
+ switch (tab) {
+ case "active":
+ return issues.filter((i) => ["todo", "in_progress", "in_review", "blocked"].includes(i.status));
+ case "backlog":
+ return issues.filter((i) => i.status === "backlog");
+ case "done":
+ return issues.filter((i) => ["done", "cancelled"].includes(i.status));
+ default:
+ return issues;
+ }
+}
export function Issues() {
const { selectedCompanyId } = useCompany();
+ const { openNewIssue } = useDialog();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const navigate = useNavigate();
+ const [tab, setTab] = useState("all");
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "Issues" }]);
+ }, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
- const { data: issues, loading, error } = useApi(fetcher);
+ const { data: issues, loading, error, reload } = useApi(fetcher);
- if (!selectedCompanyId) {
- return Select a company first.
;
+ async function handleStatusChange(issue: Issue, status: string) {
+ await issuesApi.update(issue.id, { status });
+ reload();
}
+ if (!selectedCompanyId) {
+ return ;
+ }
+
+ const filtered = filterIssues(issues ?? [], tab);
+ const grouped = groupBy(filtered, (i) => i.status);
+ const orderedGroups = statusOrder
+ .filter((s) => grouped[s]?.length)
+ .map((s) => ({ status: s, items: grouped[s]! }));
+
return (
-
-
Tasks
- {loading &&
Loading...
}
- {error &&
{error.message}
}
- {issues && issues.length === 0 &&
No tasks yet.
}
- {issues && issues.length > 0 && (
-
- {issues.map((issue) => (
-
-
-
-
-
{issue.title}
- {issue.description && (
-
{issue.description}
- )}
-
-
-
- {issue.priority}
-
-
-
-
-
-
- ))}
-
+
+
+
Issues
+
+
+
+
setTab(v as TabFilter)}>
+
+ All Issues
+ Active
+ Backlog
+ Done
+
+
+
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
+
+ {issues && filtered.length === 0 && (
+
openNewIssue()}
+ />
)}
+
+ {orderedGroups.map(({ status, items }) => (
+
+
+
+
+ {statusLabel(status)}
+
+
{items.length}
+
+
+
+ {items.map((issue) => (
+
navigate(`/issues/${issue.id}`)}
+ leading={
+ <>
+
+ handleStatusChange(issue, s)}
+ />
+ >
+ }
+ trailing={
+
+ {formatDate(issue.createdAt)}
+
+ }
+ />
+ ))}
+
+
+ ))}
);
}
diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx
new file mode 100644
index 00000000..5434c969
--- /dev/null
+++ b/ui/src/pages/MyIssues.tsx
@@ -0,0 +1,75 @@
+import { useCallback, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { issuesApi } from "../api/issues";
+import { useCompany } from "../context/CompanyContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { useApi } from "../hooks/useApi";
+import { StatusIcon } from "../components/StatusIcon";
+import { PriorityIcon } from "../components/PriorityIcon";
+import { EntityRow } from "../components/EntityRow";
+import { EmptyState } from "../components/EmptyState";
+import { formatDate } from "../lib/utils";
+import { ListTodo } from "lucide-react";
+
+export function MyIssues() {
+ const { selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "My Issues" }]);
+ }, [setBreadcrumbs]);
+
+ const fetcher = useCallback(() => {
+ if (!selectedCompanyId) return Promise.resolve([]);
+ return issuesApi.list(selectedCompanyId);
+ }, [selectedCompanyId]);
+
+ const { data: issues, loading, error } = useApi(fetcher);
+
+ if (!selectedCompanyId) {
+ return
;
+ }
+
+ // Show issues that are not assigned (user-created or unassigned)
+ const myIssues = (issues ?? []).filter(
+ (i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status)
+ );
+
+ return (
+
+
My Issues
+
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
+
+ {!loading && myIssues.length === 0 && (
+
+ )}
+
+ {myIssues.length > 0 && (
+
+ {myIssues.map((issue) => (
+
navigate(`/issues/${issue.id}`)}
+ leading={
+ <>
+
+
+ >
+ }
+ trailing={
+
+ {formatDate(issue.createdAt)}
+
+ }
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/ui/src/pages/Org.tsx b/ui/src/pages/Org.tsx
index ffe957ab..4fd3f91f 100644
--- a/ui/src/pages/Org.tsx
+++ b/ui/src/pages/Org.tsx
@@ -1,33 +1,98 @@
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
-import { Card, CardContent } from "@/components/ui/card";
+import { EmptyState } from "../components/EmptyState";
+import { ChevronRight, GitBranch } from "lucide-react";
+import { cn } from "../lib/utils";
+import { useState } from "react";
-function OrgTree({ nodes, depth = 0 }: { nodes: OrgNode[]; depth?: number }) {
+function OrgTree({
+ nodes,
+ depth = 0,
+ onSelect,
+}: {
+ nodes: OrgNode[];
+ depth?: number;
+ onSelect: (id: string) => void;
+}) {
return (
-
+
{nodes.map((node) => (
-
-
-
-
-
{node.name}
-
{node.role}
-
-
-
-
- {node.reports.length > 0 &&
}
-
+
))}
);
}
+function OrgTreeNode({
+ node,
+ depth,
+ onSelect,
+}: {
+ node: OrgNode;
+ depth: number;
+ onSelect: (id: string) => void;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ const hasChildren = node.reports.length > 0;
+
+ return (
+
+
onSelect(node.id)}
+ >
+ {hasChildren ? (
+
+ ) : (
+
+ )}
+
+ {node.name}
+ {node.role}
+
+
+ {hasChildren && expanded && (
+
+ )}
+
+ );
+}
+
export function Org() {
const { selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "Org Chart" }]);
+ }, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as OrgNode[]);
@@ -37,16 +102,25 @@ export function Org() {
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
- return
Select a company first.
;
+ return
;
}
return (
-
-
Org Chart
- {loading &&
Loading...
}
- {error &&
{error.message}
}
- {data && data.length === 0 &&
No agents in org.
}
- {data && data.length > 0 &&
}
+
+
Org Chart
+
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
+
+ {data && data.length === 0 && (
+
+ )}
+
+ {data && data.length > 0 && (
+
+ navigate(`/agents/${id}`)} />
+
+ )}
);
}
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
new file mode 100644
index 00000000..90b77dd0
--- /dev/null
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -0,0 +1,105 @@
+import { useCallback, useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { projectsApi } from "../api/projects";
+import { issuesApi } from "../api/issues";
+import { useApi } from "../hooks/useApi";
+import { usePanel } from "../context/PanelContext";
+import { useCompany } from "../context/CompanyContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { ProjectProperties } from "../components/ProjectProperties";
+import { StatusBadge } from "../components/StatusBadge";
+import { EntityRow } from "../components/EntityRow";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { Issue } from "@paperclip/shared";
+
+export function ProjectDetail() {
+ const { projectId } = useParams<{ projectId: string }>();
+ const { selectedCompanyId } = useCompany();
+ const { openPanel, closePanel } = usePanel();
+ const { setBreadcrumbs } = useBreadcrumbs();
+
+ const projectFetcher = useCallback(() => {
+ if (!projectId) return Promise.reject(new Error("No project ID"));
+ return projectsApi.get(projectId);
+ }, [projectId]);
+
+ const issuesFetcher = useCallback(() => {
+ if (!selectedCompanyId) return Promise.resolve([] as Issue[]);
+ return issuesApi.list(selectedCompanyId);
+ }, [selectedCompanyId]);
+
+ const { data: project, loading, error } = useApi(projectFetcher);
+ const { data: allIssues } = useApi(issuesFetcher);
+
+ const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: "Projects", href: "/projects" },
+ { label: project?.name ?? projectId ?? "Project" },
+ ]);
+ }, [setBreadcrumbs, project, projectId]);
+
+ useEffect(() => {
+ if (project) {
+ openPanel(
);
+ }
+ return () => closePanel();
+ }, [project]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (loading) return
Loading...
;
+ if (error) return
{error.message}
;
+ if (!project) return null;
+
+ return (
+
+
+
{project.name}
+ {project.description && (
+
{project.description}
+ )}
+
+
+
+
+ Overview
+ Issues ({projectIssues.length})
+
+
+
+
+
+ {project.targetDate && (
+
+
Target Date
+
{project.targetDate}
+
+ )}
+
+
+
+
+ {projectIssues.length === 0 ? (
+ No issues in this project.
+ ) : (
+
+ {projectIssues.map((issue) => (
+ }
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx
index 8f7a2b97..c9709c47 100644
--- a/ui/src/pages/Projects.tsx
+++ b/ui/src/pages/Projects.tsx
@@ -1,13 +1,23 @@
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
import { projectsApi } from "../api/projects";
import { useApi } from "../hooks/useApi";
-import { formatDate } from "../lib/utils";
-import { StatusBadge } from "../components/StatusBadge";
import { useCompany } from "../context/CompanyContext";
-import { Card, CardContent } from "@/components/ui/card";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { EntityRow } from "../components/EntityRow";
+import { StatusBadge } from "../components/StatusBadge";
+import { EmptyState } from "../components/EmptyState";
+import { formatDate } from "../lib/utils";
+import { Hexagon } from "lucide-react";
export function Projects() {
const { selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "Projects" }]);
+ }, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
@@ -17,34 +27,39 @@ export function Projects() {
const { data: projects, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
- return
Select a company first.
;
+ return
;
}
return (
-
-
Projects
- {loading &&
Loading...
}
- {error &&
{error.message}
}
- {projects && projects.length === 0 &&
No projects yet.
}
+
+
Projects
+
+ {loading &&
Loading...
}
+ {error &&
{error.message}
}
+
+ {projects && projects.length === 0 && (
+
+ )}
+
{projects && projects.length > 0 && (
-
+
{projects.map((project) => (
-
-
-
-
-
{project.name}
- {project.description && (
-
{project.description}
- )}
- {project.targetDate && (
-
Target: {formatDate(project.targetDate)}
- )}
-
+
navigate(`/projects/${project.id}`)}
+ trailing={
+
+ {project.targetDate && (
+
+ {formatDate(project.targetDate)}
+
+ )}
-
-
+ }
+ />
))}
)}