Adopt React Query and live updates across all UI pages
Replace custom useApi/useAgents hooks with @tanstack/react-query. Add LiveUpdatesProvider for WebSocket-driven cache invalidation. Add queryKeys module for centralized cache key management. Rework all pages and dialogs to use React Query mutations and queries. Improve CompanyContext with query-based data fetching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclip/shared": "workspace:*",
|
"@paperclip/shared": "workspace:*",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@@ -21,4 +21,14 @@ export const agentsApi = {
|
|||||||
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}),
|
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}),
|
||||||
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
||||||
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
|
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
|
||||||
|
wakeup: (
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||||
|
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
},
|
||||||
|
) => api.post<HeartbeatRun | { status: "skipped" }>(`/agents/${id}/wakeup`, data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeartbeatRun } from "@paperclip/shared";
|
import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclip/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export const heartbeatsApi = {
|
export const heartbeatsApi = {
|
||||||
@@ -6,4 +6,12 @@ export const heartbeatsApi = {
|
|||||||
const params = agentId ? `?agentId=${agentId}` : "";
|
const params = agentId ? `?agentId=${agentId}` : "";
|
||||||
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${params}`);
|
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${params}`);
|
||||||
},
|
},
|
||||||
|
events: (runId: string, afterSeq = 0, limit = 200) =>
|
||||||
|
api.get<HeartbeatRunEvent[]>(
|
||||||
|
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||||
|
),
|
||||||
|
log: (runId: string, offset = 0, limitBytes = 256000) =>
|
||||||
|
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
|
||||||
|
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -27,13 +29,9 @@ import {
|
|||||||
SquarePen,
|
SquarePen,
|
||||||
Plus,
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Issue, Agent, Project } from "@paperclip/shared";
|
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [issues, setIssues] = useState<Issue[]>([]);
|
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
@@ -49,23 +47,23 @@ export function CommandPalette() {
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const { data: issues = [] } = useQuery({
|
||||||
if (!selectedCompanyId) return;
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
const [i, a, p] = await Promise.all([
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
issuesApi.list(selectedCompanyId).catch(() => []),
|
enabled: !!selectedCompanyId && open,
|
||||||
agentsApi.list(selectedCompanyId).catch(() => []),
|
});
|
||||||
projectsApi.list(selectedCompanyId).catch(() => []),
|
|
||||||
]);
|
|
||||||
setIssues(i);
|
|
||||||
setAgents(a);
|
|
||||||
setProjects(p);
|
|
||||||
}, [selectedCompanyId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: agents = [] } = useQuery({
|
||||||
if (open) {
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
void loadData();
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
}
|
enabled: !!selectedCompanyId && open,
|
||||||
}, [open, loadData]);
|
});
|
||||||
|
|
||||||
|
const { data: projects = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId && open,
|
||||||
|
});
|
||||||
|
|
||||||
function go(path: string) {
|
function go(path: string) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Issue } from "@paperclip/shared";
|
import type { Issue } from "@paperclip/shared";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { formatDate } from "../lib/utils";
|
import { formatDate } from "../lib/utils";
|
||||||
@@ -31,7 +33,11 @@ function priorityLabel(priority: string): string {
|
|||||||
|
|
||||||
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { data: agents } = useAgents(selectedCompanyId);
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const agentName = (id: string | null) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id || !agents) return null;
|
if (!id || !agents) return null;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -47,13 +48,10 @@ const priorities = [
|
|||||||
{ value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" },
|
{ value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface NewIssueDialogProps {
|
export function NewIssueDialog() {
|
||||||
onCreated?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
|
||||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("todo");
|
const [status, setStatus] = useState("todo");
|
||||||
@@ -61,7 +59,6 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
|||||||
const [assigneeId, setAssigneeId] = useState("");
|
const [assigneeId, setAssigneeId] = useState("");
|
||||||
const [projectId, setProjectId] = useState("");
|
const [projectId, setProjectId] = useState("");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// Popover states
|
// Popover states
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
@@ -70,13 +67,27 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
|||||||
const [projectOpen, setProjectOpen] = useState(false);
|
const [projectOpen, setProjectOpen] = useState(false);
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
const { data: agents } = useAgents(selectedCompanyId);
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId && newIssueOpen,
|
||||||
|
});
|
||||||
|
|
||||||
const projectsFetcher = useCallback(() => {
|
const { data: projects } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as Project[]);
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
return projectsApi.list(selectedCompanyId);
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId && newIssueOpen,
|
||||||
const { data: projects } = useApi(projectsFetcher);
|
});
|
||||||
|
|
||||||
|
const createIssue = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
issuesApi.create(selectedCompanyId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||||
|
reset();
|
||||||
|
closeNewIssue();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newIssueOpen) {
|
if (newIssueOpen) {
|
||||||
@@ -96,12 +107,9 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
|||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!selectedCompanyId || !title.trim()) return;
|
if (!selectedCompanyId || !title.trim()) return;
|
||||||
|
createIssue.mutate({
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await issuesApi.create(selectedCompanyId, {
|
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
@@ -109,12 +117,6 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
|||||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||||
...(projectId ? { projectId } : {}),
|
...(projectId ? { projectId } : {}),
|
||||||
});
|
});
|
||||||
reset();
|
|
||||||
closeNewIssue();
|
|
||||||
onCreated?.();
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
@@ -359,10 +361,10 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
|||||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!title.trim() || submitting}
|
disabled={!title.trim() || createIssue.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{submitting ? "Creating..." : "Create issue"}
|
{createIssue.isPending ? "Creating..." : "Create issue"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -32,29 +33,35 @@ const projectStatuses = [
|
|||||||
{ value: "cancelled", label: "Cancelled" },
|
{ value: "cancelled", label: "Cancelled" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface NewProjectDialogProps {
|
export function NewProjectDialog() {
|
||||||
onCreated?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewProjectDialog({ onCreated }: NewProjectDialogProps) {
|
|
||||||
const { newProjectOpen, closeNewProject } = useDialog();
|
const { newProjectOpen, closeNewProject } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("planned");
|
const [status, setStatus] = useState("planned");
|
||||||
const [goalId, setGoalId] = useState("");
|
const [goalId, setGoalId] = useState("");
|
||||||
const [targetDate, setTargetDate] = useState("");
|
const [targetDate, setTargetDate] = useState("");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const [goalOpen, setGoalOpen] = useState(false);
|
const [goalOpen, setGoalOpen] = useState(false);
|
||||||
|
|
||||||
const goalsFetcher = useCallback(() => {
|
const { data: goals } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as Goal[]);
|
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||||
return goalsApi.list(selectedCompanyId);
|
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId && newProjectOpen,
|
||||||
const { data: goals } = useApi(goalsFetcher);
|
});
|
||||||
|
|
||||||
|
const createProject = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
projectsApi.create(selectedCompanyId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId!) });
|
||||||
|
reset();
|
||||||
|
closeNewProject();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setName("");
|
setName("");
|
||||||
@@ -65,24 +72,15 @@ export function NewProjectDialog({ onCreated }: NewProjectDialogProps) {
|
|||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!selectedCompanyId || !name.trim()) return;
|
if (!selectedCompanyId || !name.trim()) return;
|
||||||
|
createProject.mutate({
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await projectsApi.create(selectedCompanyId, {
|
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
...(goalId ? { goalId } : {}),
|
...(goalId ? { goalId } : {}),
|
||||||
...(targetDate ? { targetDate } : {}),
|
...(targetDate ? { targetDate } : {}),
|
||||||
});
|
});
|
||||||
reset();
|
|
||||||
closeNewProject();
|
|
||||||
onCreated?.();
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
@@ -239,10 +237,10 @@ export function NewProjectDialog({ onCreated }: NewProjectDialogProps) {
|
|||||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!name.trim() || submitting}
|
disabled={!name.trim() || createProject.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{submitting ? "Creating..." : "Create project"}
|
{createProject.isPending ? "Creating..." : "Create project"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Company } from "@paperclip/shared";
|
import type { Company } from "@paperclip/shared";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
interface CompanyContextValue {
|
interface CompanyContextValue {
|
||||||
companies: Company[];
|
companies: Company[];
|
||||||
@@ -30,10 +32,28 @@ const STORAGE_KEY = "paperclip.selectedCompanyId";
|
|||||||
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
||||||
|
|
||||||
export function CompanyProvider({ children }: { children: ReactNode }) {
|
export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||||
const [companies, setCompanies] = useState<Company[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
|
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(
|
||||||
const [loading, setLoading] = useState(true);
|
() => localStorage.getItem(STORAGE_KEY)
|
||||||
const [error, setError] = useState<Error | null>(null);
|
);
|
||||||
|
|
||||||
|
const { data: companies = [], isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.companies.all,
|
||||||
|
queryFn: () => companiesApi.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select first company when list loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (companies.length === 0) return;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored && companies.some((c) => c.id === stored)) return;
|
||||||
|
if (selectedCompanyId && companies.some((c) => c.id === selectedCompanyId)) return;
|
||||||
|
|
||||||
|
const next = companies[0]!.id;
|
||||||
|
setSelectedCompanyIdState(next);
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
}, [companies, selectedCompanyId]);
|
||||||
|
|
||||||
const setSelectedCompanyId = useCallback((companyId: string) => {
|
const setSelectedCompanyId = useCallback((companyId: string) => {
|
||||||
setSelectedCompanyIdState(companyId);
|
setSelectedCompanyIdState(companyId);
|
||||||
@@ -41,47 +61,23 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reloadCompanies = useCallback(async () => {
|
const reloadCompanies = useCallback(async () => {
|
||||||
setLoading(true);
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
setError(null);
|
}, [queryClient]);
|
||||||
try {
|
|
||||||
const rows = await companiesApi.list();
|
|
||||||
setCompanies(rows);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
const createMutation = useMutation({
|
||||||
setSelectedCompanyIdState(null);
|
mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
|
||||||
return;
|
companiesApi.create(data),
|
||||||
}
|
onSuccess: (company) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
setSelectedCompanyId(company.id);
|
||||||
const next = rows.some((company) => company.id === stored)
|
},
|
||||||
? stored
|
});
|
||||||
: selectedCompanyId && rows.some((company) => company.id === selectedCompanyId)
|
|
||||||
? selectedCompanyId
|
|
||||||
: rows[0]!.id;
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
setSelectedCompanyIdState(next);
|
|
||||||
localStorage.setItem(STORAGE_KEY, next);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err : new Error("Failed to load companies"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedCompanyId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void reloadCompanies();
|
|
||||||
}, [reloadCompanies]);
|
|
||||||
|
|
||||||
const createCompany = useCallback(
|
const createCompany = useCallback(
|
||||||
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
|
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
|
||||||
const company = await companiesApi.create(data);
|
return createMutation.mutateAsync(data);
|
||||||
await reloadCompanies();
|
|
||||||
setSelectedCompanyId(company.id);
|
|
||||||
return company;
|
|
||||||
},
|
},
|
||||||
[reloadCompanies, setSelectedCompanyId],
|
[createMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedCompany = useMemo(
|
const selectedCompany = useMemo(
|
||||||
@@ -94,8 +90,8 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
companies,
|
companies,
|
||||||
selectedCompanyId,
|
selectedCompanyId,
|
||||||
selectedCompany,
|
selectedCompany,
|
||||||
loading,
|
loading: isLoading,
|
||||||
error,
|
error: error as Error | null,
|
||||||
setSelectedCompanyId,
|
setSelectedCompanyId,
|
||||||
reloadCompanies,
|
reloadCompanies,
|
||||||
createCompany,
|
createCompany,
|
||||||
@@ -104,7 +100,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
companies,
|
companies,
|
||||||
selectedCompanyId,
|
selectedCompanyId,
|
||||||
selectedCompany,
|
selectedCompany,
|
||||||
loading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
setSelectedCompanyId,
|
setSelectedCompanyId,
|
||||||
reloadCompanies,
|
reloadCompanies,
|
||||||
|
|||||||
193
ui/src/context/LiveUpdatesProvider.tsx
Normal file
193
ui/src/context/LiveUpdatesProvider.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { LiveEvent } from "@paperclip/shared";
|
||||||
|
import { useCompany } from "./CompanyContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateHeartbeatQueries(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
companyId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||||
|
|
||||||
|
const agentId = readString(payload.agentId);
|
||||||
|
if (agentId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateActivityQueries(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
companyId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||||
|
|
||||||
|
const entityType = readString(payload.entityType);
|
||||||
|
const entityId = readString(payload.entityId);
|
||||||
|
|
||||||
|
if (entityType === "issue") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
if (entityId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === "agent") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.org(companyId) });
|
||||||
|
if (entityId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(entityId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, entityId) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === "project") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) });
|
||||||
|
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === "goal") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(companyId) });
|
||||||
|
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(entityId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === "approval") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === "cost_event") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === "company") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLiveEvent(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
expectedCompanyId: string,
|
||||||
|
event: LiveEvent,
|
||||||
|
) {
|
||||||
|
if (event.companyId !== expectedCompanyId) return;
|
||||||
|
|
||||||
|
const payload = event.payload ?? {};
|
||||||
|
if (event.type === "heartbeat.run.log") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status" || event.type === "heartbeat.run.event") {
|
||||||
|
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "agent.status") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(expectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.org(expectedCompanyId) });
|
||||||
|
const agentId = readString(payload.agentId);
|
||||||
|
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "activity.logged") {
|
||||||
|
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCompanyId) return;
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
|
||||||
|
const clearReconnect = () => {
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
const delayMs = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4));
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`;
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (message) => {
|
||||||
|
const raw = typeof message.data === "string" ? message.data : "";
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as LiveEvent;
|
||||||
|
handleLiveEvent(queryClient, selectedCompanyId, parsed);
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON payloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (closed) return;
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
clearReconnect();
|
||||||
|
if (socket) {
|
||||||
|
socket.onopen = null;
|
||||||
|
socket.onmessage = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close(1000, "provider_unmount");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [queryClient, selectedCompanyId]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { agentsApi } from "../api/agents";
|
|
||||||
import { useApi } from "./useApi";
|
|
||||||
|
|
||||||
export function useAgents(companyId: string | null) {
|
|
||||||
const fetcher = useCallback(() => {
|
|
||||||
if (!companyId) return Promise.resolve([]);
|
|
||||||
return agentsApi.list(companyId);
|
|
||||||
}, [companyId]);
|
|
||||||
return useApi(fetcher);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
|
|
||||||
export function useApi<T>(fetcher: () => Promise<T>) {
|
|
||||||
const [data, setData] = useState<T | null>(null);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
fetcher()
|
|
||||||
.then(setData)
|
|
||||||
.catch(setError)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [fetcher]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return { data, error, loading, reload: load };
|
|
||||||
}
|
|
||||||
33
ui/src/lib/queryKeys.ts
Normal file
33
ui/src/lib/queryKeys.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const queryKeys = {
|
||||||
|
companies: {
|
||||||
|
all: ["companies"] as const,
|
||||||
|
detail: (id: string) => ["companies", id] as const,
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: (companyId: string) => ["agents", companyId] as const,
|
||||||
|
detail: (id: string) => ["agents", "detail", id] as const,
|
||||||
|
},
|
||||||
|
issues: {
|
||||||
|
list: (companyId: string) => ["issues", companyId] as const,
|
||||||
|
detail: (id: string) => ["issues", "detail", id] as const,
|
||||||
|
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
list: (companyId: string) => ["projects", companyId] as const,
|
||||||
|
detail: (id: string) => ["projects", "detail", id] as const,
|
||||||
|
},
|
||||||
|
goals: {
|
||||||
|
list: (companyId: string) => ["goals", companyId] as const,
|
||||||
|
detail: (id: string) => ["goals", "detail", id] as const,
|
||||||
|
},
|
||||||
|
approvals: {
|
||||||
|
list: (companyId: string, status?: string) =>
|
||||||
|
["approvals", companyId, status] as const,
|
||||||
|
},
|
||||||
|
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||||
|
activity: (companyId: string) => ["activity", companyId] as const,
|
||||||
|
costs: (companyId: string) => ["costs", companyId] as const,
|
||||||
|
heartbeats: (companyId: string, agentId?: string) =>
|
||||||
|
["heartbeats", companyId, agentId] as const,
|
||||||
|
org: (companyId: string) => ["org", companyId] as const,
|
||||||
|
};
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { CompanyProvider } from "./context/CompanyContext";
|
import { CompanyProvider } from "./context/CompanyContext";
|
||||||
|
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
||||||
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||||
import { PanelProvider } from "./context/PanelContext";
|
import { PanelProvider } from "./context/PanelContext";
|
||||||
import { DialogProvider } from "./context/DialogContext";
|
import { DialogProvider } from "./context/DialogContext";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<CompanyProvider>
|
<CompanyProvider>
|
||||||
|
<LiveUpdatesProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
@@ -23,6 +36,8 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
</BreadcrumbProvider>
|
</BreadcrumbProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</LiveUpdatesProvider>
|
||||||
</CompanyProvider>
|
</CompanyProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
import { History, Bot, User, Settings } from "lucide-react";
|
import { History, Bot, User, Settings } from "lucide-react";
|
||||||
|
|
||||||
function formatAction(action: string, entityType: string, entityId: string): string {
|
function formatAction(action: string, entityType: string, entityId: string): string {
|
||||||
const shortId = entityId.slice(0, 8);
|
|
||||||
const actionMap: Record<string, string> = {
|
const actionMap: Record<string, string> = {
|
||||||
"company.created": "Company created",
|
"company.created": "Company created",
|
||||||
"agent.created": `Agent created`,
|
"agent.created": `Agent created`,
|
||||||
@@ -80,12 +80,11 @@ export function Activity() {
|
|||||||
setBreadcrumbs([{ label: "Activity" }]);
|
setBreadcrumbs([{ label: "Activity" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.activity(selectedCompanyId!),
|
||||||
return activityApi.list(selectedCompanyId);
|
queryFn: () => activityApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data, loading, error } = useApi(fetcher);
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={History} message="Select a company to view activity." />;
|
return <EmptyState icon={History} message="Select a company to view activity." />;
|
||||||
@@ -119,7 +118,7 @@ export function Activity() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{filtered && filtered.length === 0 && (
|
{filtered && filtered.length === 0 && (
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useApi } from "../hooks/useApi";
|
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentProperties } from "../components/AgentProperties";
|
import { AgentProperties } from "../components/AgentProperties";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
@@ -20,29 +21,53 @@ export function AgentDetail() {
|
|||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openPanel, closePanel } = usePanel();
|
const { openPanel, closePanel } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const agentFetcher = useCallback(() => {
|
const { data: agent, isLoading, error } = useQuery({
|
||||||
if (!agentId) return Promise.reject(new Error("No agent ID"));
|
queryKey: queryKeys.agents.detail(agentId!),
|
||||||
return agentsApi.get(agentId);
|
queryFn: () => agentsApi.get(agentId!),
|
||||||
}, [agentId]);
|
enabled: !!agentId,
|
||||||
|
});
|
||||||
|
|
||||||
const heartbeatsFetcher = useCallback(() => {
|
const { data: heartbeats } = useQuery({
|
||||||
if (!selectedCompanyId || !agentId) return Promise.resolve([] as HeartbeatRun[]);
|
queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId),
|
||||||
return heartbeatsApi.list(selectedCompanyId, agentId);
|
queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId),
|
||||||
}, [selectedCompanyId, agentId]);
|
enabled: !!selectedCompanyId && !!agentId,
|
||||||
|
});
|
||||||
|
|
||||||
const issuesFetcher = useCallback(() => {
|
const { data: allIssues } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as Issue[]);
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
return issuesApi.list(selectedCompanyId);
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: agent, loading, error, reload: reloadAgent } = useApi(agentFetcher);
|
|
||||||
const { data: heartbeats } = useApi(heartbeatsFetcher);
|
|
||||||
const { data: allIssues } = useApi(issuesFetcher);
|
|
||||||
|
|
||||||
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
|
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
|
||||||
|
|
||||||
|
const agentAction = useMutation({
|
||||||
|
mutationFn: async (action: "invoke" | "pause" | "resume") => {
|
||||||
|
if (!agentId) return Promise.reject(new Error("No agent ID"));
|
||||||
|
if (action === "invoke") {
|
||||||
|
await agentsApi.invoke(agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "pause") {
|
||||||
|
await agentsApi.pause(agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await agentsApi.resume(agentId);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setActionError(err instanceof Error ? err.message : "Action failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{ label: "Agents", href: "/agents" },
|
{ label: "Agents", href: "/agents" },
|
||||||
@@ -57,20 +82,7 @@ export function AgentDetail() {
|
|||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [agent]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [agent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
async function handleAction(action: "invoke" | "pause" | "resume") {
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
if (!agentId) return;
|
|
||||||
setActionError(null);
|
|
||||||
try {
|
|
||||||
if (action === "invoke") await agentsApi.invoke(agentId);
|
|
||||||
else if (action === "pause") await agentsApi.pause(agentId);
|
|
||||||
else await agentsApi.resume(agentId);
|
|
||||||
reloadAgent();
|
|
||||||
} catch (err) {
|
|
||||||
setActionError(err instanceof Error ? err.message : `Failed to ${action} agent`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
||||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
if (!agent) return null;
|
if (!agent) return null;
|
||||||
|
|
||||||
@@ -89,15 +101,15 @@ export function AgentDetail() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleAction("invoke")}>
|
<Button variant="outline" size="sm" onClick={() => agentAction.mutate("invoke")}>
|
||||||
Invoke
|
Invoke
|
||||||
</Button>
|
</Button>
|
||||||
{agent.status === "active" ? (
|
{agent.status === "active" ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => handleAction("pause")}>
|
<Button variant="outline" size="sm" onClick={() => agentAction.mutate("pause")}>
|
||||||
Pause
|
Pause
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => handleAction("resume")}>
|
<Button variant="outline" size="sm" onClick={() => agentAction.mutate("resume")}>
|
||||||
Resume
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
@@ -12,10 +13,14 @@ import { Bot } from "lucide-react";
|
|||||||
|
|
||||||
export function Agents() {
|
export function Agents() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { data: agents, loading, error, reload } = useAgents(selectedCompanyId);
|
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
|
||||||
|
const { data: agents, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Agents" }]);
|
setBreadcrumbs([{ label: "Agents" }]);
|
||||||
@@ -29,9 +34,8 @@ export function Agents() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Agents</h2>
|
<h2 className="text-lg font-semibold">Agents</h2>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
|
||||||
|
|
||||||
{agents && agents.length === 0 && (
|
{agents && agents.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function Approvals() {
|
export function Approvals() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
||||||
return approvalsApi.list(selectedCompanyId);
|
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, loading, error, reload } = useApi(fetcher);
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||||
async function approve(id: string) {
|
onSuccess: () => {
|
||||||
setActionError(null);
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||||
try {
|
},
|
||||||
await approvalsApi.approve(id);
|
onError: (err) => {
|
||||||
reload();
|
|
||||||
} catch (err) {
|
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
async function reject(id: string) {
|
const rejectMutation = useMutation({
|
||||||
setActionError(null);
|
mutationFn: (id: string) => approvalsApi.reject(id),
|
||||||
try {
|
onSuccess: () => {
|
||||||
await approvalsApi.reject(id);
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||||
reload();
|
},
|
||||||
} catch (err) {
|
onError: (err) => {
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <p className="text-muted-foreground">Select a company first.</p>;
|
return <p className="text-muted-foreground">Select a company first.</p>;
|
||||||
@@ -44,7 +45,7 @@ export function Approvals() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Approvals</h2>
|
<h2 className="text-2xl font-bold mb-4">Approvals</h2>
|
||||||
{loading && <p className="text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-destructive">{error.message}</p>}
|
{error && <p className="text-destructive">{error.message}</p>}
|
||||||
{actionError && <p className="text-destructive">{actionError}</p>}
|
{actionError && <p className="text-destructive">{actionError}</p>}
|
||||||
|
|
||||||
@@ -68,14 +69,14 @@ export function Approvals() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-green-700 text-green-400 hover:bg-green-900/50"
|
className="border-green-700 text-green-400 hover:bg-green-900/50"
|
||||||
onClick={() => approve(approval.id)}
|
onClick={() => approveMutation.mutate(approval.id)}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => reject(approval.id)}
|
onClick={() => rejectMutation.mutate(approval.id)}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { formatCents } from "../lib/utils";
|
import { formatCents } from "../lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -16,9 +18,9 @@ export function Companies() {
|
|||||||
createCompany,
|
createCompany,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
reloadCompanies,
|
|
||||||
} = useCompany();
|
} = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [budget, setBudget] = useState("0");
|
const [budget, setBudget] = useState("0");
|
||||||
@@ -28,7 +30,15 @@ export function Companies() {
|
|||||||
// Inline edit state
|
// Inline edit state
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editSaving, setEditSaving] = useState(false);
|
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: ({ id, newName }: { id: string; newName: string }) =>
|
||||||
|
companiesApi.update(id, { name: newName }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
|
setEditingId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Companies" }]);
|
setBreadcrumbs([{ label: "Companies" }]);
|
||||||
@@ -49,7 +59,6 @@ export function Companies() {
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setBudget("0");
|
setBudget("0");
|
||||||
await reloadCompanies();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(err instanceof Error ? err.message : "Failed to create company");
|
setSubmitError(err instanceof Error ? err.message : "Failed to create company");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,16 +71,9 @@ export function Companies() {
|
|||||||
setEditName(currentName);
|
setEditName(currentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
function saveEdit() {
|
||||||
if (!editingId || !editName.trim()) return;
|
if (!editingId || !editName.trim()) return;
|
||||||
setEditSaving(true);
|
editMutation.mutate({ id: editingId, newName: editName.trim() });
|
||||||
try {
|
|
||||||
await companiesApi.update(editingId, { name: editName.trim() });
|
|
||||||
await reloadCompanies();
|
|
||||||
setEditingId(null);
|
|
||||||
} finally {
|
|
||||||
setEditSaving(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -151,7 +153,7 @@ export function Companies() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
disabled={editSaving}
|
disabled={editMutation.isPending}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { costsApi } from "../api/costs";
|
import { costsApi } from "../api/costs";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { formatCents } from "../lib/utils";
|
import { formatCents } from "../lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -16,17 +17,18 @@ export function Costs() {
|
|||||||
setBreadcrumbs([{ label: "Costs" }]);
|
setBreadcrumbs([{ label: "Costs" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(async () => {
|
const { data, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return null;
|
queryKey: queryKeys.costs(selectedCompanyId!),
|
||||||
|
queryFn: async () => {
|
||||||
const [summary, byAgent, byProject] = await Promise.all([
|
const [summary, byAgent, byProject] = await Promise.all([
|
||||||
costsApi.summary(selectedCompanyId),
|
costsApi.summary(selectedCompanyId!),
|
||||||
costsApi.byAgent(selectedCompanyId),
|
costsApi.byAgent(selectedCompanyId!),
|
||||||
costsApi.byProject(selectedCompanyId),
|
costsApi.byProject(selectedCompanyId!),
|
||||||
]);
|
]);
|
||||||
return { summary, byAgent, byProject };
|
return { summary, byAgent, byProject };
|
||||||
}, [selectedCompanyId]);
|
},
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
const { data, loading, error } = useApi(fetcher);
|
});
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
|
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
|
||||||
@@ -36,7 +38,7 @@ export function Costs() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold">Costs</h2>
|
<h2 className="text-lg font-semibold">Costs</h2>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
@@ -56,30 +57,34 @@ export function Dashboard() {
|
|||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: agents } = useAgents(selectedCompanyId);
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Dashboard" }]);
|
setBreadcrumbs([{ label: "Dashboard" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const dashFetcher = useCallback(() => {
|
const { data, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve(null);
|
queryKey: queryKeys.dashboard(selectedCompanyId!),
|
||||||
return dashboardApi.summary(selectedCompanyId);
|
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const activityFetcher = useCallback(() => {
|
const { data: activity } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.activity(selectedCompanyId!),
|
||||||
return activityApi.list(selectedCompanyId);
|
queryFn: () => activityApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const issuesFetcher = useCallback(() => {
|
const { data: issues } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
return issuesApi.list(selectedCompanyId);
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data, loading, error } = useApi(dashFetcher);
|
|
||||||
const { data: activity } = useApi(activityFetcher);
|
|
||||||
const { data: issues } = useApi(issuesFetcher);
|
|
||||||
|
|
||||||
const staleIssues = issues ? getStaleIssues(issues) : [];
|
const staleIssues = issues ? getStaleIssues(issues) : [];
|
||||||
|
|
||||||
@@ -103,7 +108,7 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useApi } from "../hooks/useApi";
|
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { GoalProperties } from "../components/GoalProperties";
|
import { GoalProperties } from "../components/GoalProperties";
|
||||||
import { GoalTree } from "../components/GoalTree";
|
import { GoalTree } from "../components/GoalTree";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
@@ -20,24 +21,23 @@ export function GoalDetail() {
|
|||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const goalFetcher = useCallback(() => {
|
const { data: goal, isLoading, error } = useQuery({
|
||||||
if (!goalId) return Promise.reject(new Error("No goal ID"));
|
queryKey: queryKeys.goals.detail(goalId!),
|
||||||
return goalsApi.get(goalId);
|
queryFn: () => goalsApi.get(goalId!),
|
||||||
}, [goalId]);
|
enabled: !!goalId,
|
||||||
|
});
|
||||||
|
|
||||||
const allGoalsFetcher = useCallback(() => {
|
const { data: allGoals } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as Goal[]);
|
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||||
return goalsApi.list(selectedCompanyId);
|
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const projectsFetcher = useCallback(() => {
|
const { data: allProjects } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as Project[]);
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
return projectsApi.list(selectedCompanyId);
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!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 childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
|
||||||
const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId);
|
const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId);
|
||||||
@@ -56,7 +56,7 @@ export function GoalDetail() {
|
|||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
if (!goal) return null;
|
if (!goal) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { useApi } from "../hooks/useApi";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { GoalTree } from "../components/GoalTree";
|
import { GoalTree } from "../components/GoalTree";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { Target } from "lucide-react";
|
import { Target } from "lucide-react";
|
||||||
@@ -17,12 +18,11 @@ export function Goals() {
|
|||||||
setBreadcrumbs([{ label: "Goals" }]);
|
setBreadcrumbs([{ label: "Goals" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data: goals, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||||
return goalsApi.list(selectedCompanyId);
|
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: goals, loading, error } = useApi(fetcher);
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Target} message="Select a company to view goals." />;
|
return <EmptyState icon={Target} message="Select a company to view goals." />;
|
||||||
@@ -32,7 +32,7 @@ export function Goals() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Goals</h2>
|
<h2 className="text-lg font-semibold">Goals</h2>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{goals && goals.length === 0 && (
|
{goals && goals.length === 0 && (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useApi } from "../hooks/useApi";
|
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
@@ -43,31 +43,36 @@ export function Inbox() {
|
|||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const { data: agents } = useAgents(selectedCompanyId);
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Inbox" }]);
|
setBreadcrumbs([{ label: "Inbox" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const approvalsFetcher = useCallback(() => {
|
const { data: approvals, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending"),
|
||||||
return approvalsApi.list(selectedCompanyId, "pending");
|
queryFn: () => approvalsApi.list(selectedCompanyId!, "pending"),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const dashboardFetcher = useCallback(() => {
|
const { data: dashboard } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve(null);
|
queryKey: queryKeys.dashboard(selectedCompanyId!),
|
||||||
return dashboardApi.summary(selectedCompanyId);
|
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const issuesFetcher = useCallback(() => {
|
const { data: issues } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
return issuesApi.list(selectedCompanyId);
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: approvals, loading, error, reload } = useApi(approvalsFetcher);
|
|
||||||
const { data: dashboard } = useApi(dashboardFetcher);
|
|
||||||
const { data: issues } = useApi(issuesFetcher);
|
|
||||||
|
|
||||||
const staleIssues = issues ? getStaleIssues(issues) : [];
|
const staleIssues = issues ? getStaleIssues(issues) : [];
|
||||||
|
|
||||||
@@ -77,25 +82,25 @@ export function Inbox() {
|
|||||||
return agent?.name ?? null;
|
return agent?.name ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function approve(id: string) {
|
const approveMutation = useMutation({
|
||||||
setActionError(null);
|
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||||
try {
|
onSuccess: () => {
|
||||||
await approvalsApi.approve(id);
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending") });
|
||||||
reload();
|
},
|
||||||
} catch (err) {
|
onError: (err) => {
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
async function reject(id: string) {
|
const rejectMutation = useMutation({
|
||||||
setActionError(null);
|
mutationFn: (id: string) => approvalsApi.reject(id),
|
||||||
try {
|
onSuccess: () => {
|
||||||
await approvalsApi.reject(id);
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending") });
|
||||||
reload();
|
},
|
||||||
} catch (err) {
|
onError: (err) => {
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||||
@@ -113,11 +118,11 @@ export function Inbox() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold">Inbox</h2>
|
<h2 className="text-lg font-semibold">Inbox</h2>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
|
|
||||||
{!loading && !hasContent && (
|
{!isLoading && !hasContent && (
|
||||||
<EmptyState icon={InboxIcon} message="You're all caught up!" />
|
<EmptyState icon={InboxIcon} message="You're all caught up!" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -152,14 +157,14 @@ export function Inbox() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-green-700 text-green-500 hover:bg-green-900/20"
|
className="border-green-700 text-green-500 hover:bg-green-900/20"
|
||||||
onClick={() => approve(approval.id)}
|
onClick={() => approveMutation.mutate(approval.id)}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => reject(approval.id)}
|
onClick={() => rejectMutation.mutate(approval.id)}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,34 +1,53 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { CommentThread } from "../components/CommentThread";
|
import { CommentThread } from "../components/CommentThread";
|
||||||
import { IssueProperties } from "../components/IssueProperties";
|
import { IssueProperties } from "../components/IssueProperties";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import type { IssueComment } from "@paperclip/shared";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
const { issueId } = useParams<{ issueId: string }>();
|
const { issueId } = useParams<{ issueId: string }>();
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openPanel, closePanel } = usePanel();
|
const { openPanel, closePanel } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const issueFetcher = useCallback(() => {
|
const { data: issue, isLoading, error } = useQuery({
|
||||||
if (!issueId) return Promise.reject(new Error("No issue ID"));
|
queryKey: queryKeys.issues.detail(issueId!),
|
||||||
return issuesApi.get(issueId);
|
queryFn: () => issuesApi.get(issueId!),
|
||||||
}, [issueId]);
|
enabled: !!issueId,
|
||||||
|
});
|
||||||
|
|
||||||
const commentsFetcher = useCallback(() => {
|
const { data: comments } = useQuery({
|
||||||
if (!issueId) return Promise.resolve([] as IssueComment[]);
|
queryKey: queryKeys.issues.comments(issueId!),
|
||||||
return issuesApi.listComments(issueId);
|
queryFn: () => issuesApi.listComments(issueId!),
|
||||||
}, [issueId]);
|
enabled: !!issueId,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: issue, loading, error, reload: reloadIssue } = useApi(issueFetcher);
|
const updateIssue = useMutation({
|
||||||
const { data: comments, reload: reloadComments } = useApi(commentsFetcher);
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addComment = useMutation({
|
||||||
|
mutationFn: (body: string) => issuesApi.addComment(issueId!, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
@@ -37,28 +56,16 @@ export function IssueDetail() {
|
|||||||
]);
|
]);
|
||||||
}, [setBreadcrumbs, issue, issueId]);
|
}, [setBreadcrumbs, issue, issueId]);
|
||||||
|
|
||||||
async function handleUpdate(data: Record<string, unknown>) {
|
|
||||||
if (!issueId) return;
|
|
||||||
await issuesApi.update(issueId, data);
|
|
||||||
reloadIssue();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddComment(body: string) {
|
|
||||||
if (!issueId) return;
|
|
||||||
await issuesApi.addComment(issueId, body);
|
|
||||||
reloadComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue) {
|
if (issue) {
|
||||||
openPanel(
|
openPanel(
|
||||||
<IssueProperties issue={issue} onUpdate={handleUpdate} />
|
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
if (!issue) return null;
|
if (!issue) return null;
|
||||||
|
|
||||||
@@ -68,25 +75,25 @@ export function IssueDetail() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
status={issue.status}
|
status={issue.status}
|
||||||
onChange={(status) => handleUpdate({ status })}
|
onChange={(status) => updateIssue.mutate({ status })}
|
||||||
/>
|
/>
|
||||||
<PriorityIcon
|
<PriorityIcon
|
||||||
priority={issue.priority}
|
priority={issue.priority}
|
||||||
onChange={(priority) => handleUpdate({ priority })}
|
onChange={(priority) => updateIssue.mutate({ priority })}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{issue.id.slice(0, 8)}</span>
|
<span className="text-xs font-mono text-muted-foreground">{issue.id.slice(0, 8)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
value={issue.title}
|
value={issue.title}
|
||||||
onSave={(title) => handleUpdate({ title })}
|
onSave={(title) => updateIssue.mutate({ title })}
|
||||||
as="h2"
|
as="h2"
|
||||||
className="text-xl font-bold"
|
className="text-xl font-bold"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
value={issue.description ?? ""}
|
value={issue.description ?? ""}
|
||||||
onSave={(description) => handleUpdate({ description })}
|
onSave={(description) => updateIssue.mutate({ description })}
|
||||||
as="p"
|
as="p"
|
||||||
className="text-sm text-muted-foreground"
|
className="text-sm text-muted-foreground"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
@@ -98,7 +105,9 @@ export function IssueDetail() {
|
|||||||
|
|
||||||
<CommentThread
|
<CommentThread
|
||||||
comments={comments ?? []}
|
comments={comments ?? []}
|
||||||
onAdd={handleAddComment}
|
onAdd={async (body) => {
|
||||||
|
await addComment.mutateAsync(body);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { agentsApi } from "../api/agents";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
@@ -43,30 +44,38 @@ export function Issues() {
|
|||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<TabFilter>("all");
|
const [tab, setTab] = useState<TabFilter>("all");
|
||||||
const { data: agents } = useAgents(selectedCompanyId);
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Issues" }]);
|
setBreadcrumbs([{ label: "Issues" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data: issues, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
return issuesApi.list(selectedCompanyId);
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: issues, loading, error, reload } = useApi(fetcher);
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||||
|
issuesApi.update(id, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const agentName = (id: string | null) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id || !agents) return null;
|
if (!id || !agents) return null;
|
||||||
return agents.find((a) => a.id === id)?.name ?? null;
|
return agents.find((a) => a.id === id)?.name ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleStatusChange(issue: Issue, status: string) {
|
|
||||||
await issuesApi.update(issue.id, { status });
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
|
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
|
||||||
}
|
}
|
||||||
@@ -96,7 +105,7 @@ export function Issues() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{issues && filtered.length === 0 && (
|
{issues && filtered.length === 0 && (
|
||||||
@@ -137,7 +146,7 @@ export function Issues() {
|
|||||||
<PriorityIcon priority={issue.priority} />
|
<PriorityIcon priority={issue.priority} />
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
status={issue.status}
|
status={issue.status}
|
||||||
onChange={(s) => handleStatusChange(issue, s)}
|
onChange={(s) => updateStatus.mutate({ id: issue.id, status: s })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
@@ -20,12 +21,11 @@ export function MyIssues() {
|
|||||||
setBreadcrumbs([{ label: "My Issues" }]);
|
setBreadcrumbs([{ label: "My Issues" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data: issues, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
return issuesApi.list(selectedCompanyId);
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: issues, loading, error } = useApi(fetcher);
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
|
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
|
||||||
@@ -40,10 +40,10 @@ export function MyIssues() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">My Issues</h2>
|
<h2 className="text-lg font-semibold">My Issues</h2>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{!loading && myIssues.length === 0 && (
|
{!isLoading && myIssues.length === 0 && (
|
||||||
<EmptyState icon={ListTodo} message="No issues assigned to you." />
|
<EmptyState icon={ListTodo} message="No issues assigned to you." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { agentsApi, type OrgNode } from "../api/agents";
|
import { agentsApi, type OrgNode } from "../api/agents";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { ChevronRight, GitBranch } from "lucide-react";
|
import { ChevronRight, GitBranch } from "lucide-react";
|
||||||
@@ -93,12 +94,11 @@ export function Org() {
|
|||||||
setBreadcrumbs([{ label: "Org Chart" }]);
|
setBreadcrumbs([{ label: "Org Chart" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as OrgNode[]);
|
queryKey: queryKeys.org(selectedCompanyId!),
|
||||||
return agentsApi.org(selectedCompanyId);
|
queryFn: () => agentsApi.org(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data, loading, error } = useApi(fetcher);
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={GitBranch} message="Select a company to view org chart." />;
|
return <EmptyState icon={GitBranch} message="Select a company to view org chart." />;
|
||||||
@@ -108,7 +108,7 @@ export function Org() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Org Chart</h2>
|
<h2 className="text-lg font-semibold">Org Chart</h2>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{data && data.length === 0 && (
|
{data && data.length === 0 && (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useApi } from "../hooks/useApi";
|
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { ProjectProperties } from "../components/ProjectProperties";
|
import { ProjectProperties } from "../components/ProjectProperties";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
@@ -18,18 +19,17 @@ export function ProjectDetail() {
|
|||||||
const { openPanel, closePanel } = usePanel();
|
const { openPanel, closePanel } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
||||||
const projectFetcher = useCallback(() => {
|
const { data: project, isLoading, error } = useQuery({
|
||||||
if (!projectId) return Promise.reject(new Error("No project ID"));
|
queryKey: queryKeys.projects.detail(projectId!),
|
||||||
return projectsApi.get(projectId);
|
queryFn: () => projectsApi.get(projectId!),
|
||||||
}, [projectId]);
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
const issuesFetcher = useCallback(() => {
|
const { data: allIssues } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([] as Issue[]);
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
return issuesApi.list(selectedCompanyId);
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: project, loading, error } = useApi(projectFetcher);
|
|
||||||
const { data: allIssues } = useApi(issuesFetcher);
|
|
||||||
|
|
||||||
const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId);
|
const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId);
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function ProjectDetail() {
|
|||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useApi } from "../hooks/useApi";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
@@ -22,12 +23,11 @@ export function Projects() {
|
|||||||
setBreadcrumbs([{ label: "Projects" }]);
|
setBreadcrumbs([{ label: "Projects" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const fetcher = useCallback(() => {
|
const { data: projects, isLoading, error } = useQuery({
|
||||||
if (!selectedCompanyId) return Promise.resolve([]);
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
return projectsApi.list(selectedCompanyId);
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
}, [selectedCompanyId]);
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: projects, loading, error } = useApi(fetcher);
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||||
@@ -43,7 +43,7 @@ export function Projects() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
{projects && projects.length === 0 && (
|
{projects && projects.length === 0 && (
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:3100",
|
"/api": {
|
||||||
|
target: "http://localhost:3100",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user