Add ApprovalDetail page with comment thread, revision request/resubmit flow, and ApprovalPayload component for structured payload display. Extend AgentDetail with permissions management, config revision history, and duplicate action. Add agent hire dialog with permission-gated access. Rework Costs page with per-agent breakdown table and period filtering. Add sidebar badge counts for pending approvals and inbox items. Enhance Dashboard with live metrics and sparkline trends. Extend Agents list with pending_approval status and bulk actions. Update IssueDetail with approval linking. Various component improvements to MetricCard, InlineEditor, CommentThread, and StatusBadge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { issuesApi } from "../api/issues";
|
|
import { projectsApi } from "../api/projects";
|
|
import { agentsApi } from "../api/agents";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Maximize2,
|
|
Minimize2,
|
|
MoreHorizontal,
|
|
CircleDot,
|
|
Minus,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
AlertTriangle,
|
|
User,
|
|
Hexagon,
|
|
Tag,
|
|
Calendar,
|
|
} from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import type { Project, Agent } from "@paperclip/shared";
|
|
|
|
const DRAFT_KEY = "paperclip:issue-draft";
|
|
const DEBOUNCE_MS = 800;
|
|
|
|
interface IssueDraft {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
assigneeId: string;
|
|
projectId: string;
|
|
}
|
|
|
|
function loadDraft(): IssueDraft | null {
|
|
try {
|
|
const raw = localStorage.getItem(DRAFT_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as IssueDraft;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveDraft(draft: IssueDraft) {
|
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
|
}
|
|
|
|
function clearDraft() {
|
|
localStorage.removeItem(DRAFT_KEY);
|
|
}
|
|
|
|
const statuses = [
|
|
{ value: "backlog", label: "Backlog", color: "text-muted-foreground" },
|
|
{ value: "todo", label: "Todo", color: "text-blue-400" },
|
|
{ value: "in_progress", label: "In Progress", color: "text-yellow-400" },
|
|
{ value: "in_review", label: "In Review", color: "text-violet-400" },
|
|
{ value: "done", label: "Done", color: "text-green-400" },
|
|
];
|
|
|
|
const priorities = [
|
|
{ value: "critical", label: "Critical", icon: AlertTriangle, color: "text-red-400" },
|
|
{ value: "high", label: "High", icon: ArrowUp, color: "text-orange-400" },
|
|
{ value: "medium", label: "Medium", icon: Minus, color: "text-yellow-400" },
|
|
{ value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" },
|
|
];
|
|
|
|
export function NewIssueDialog() {
|
|
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
|
const queryClient = useQueryClient();
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [status, setStatus] = useState("todo");
|
|
const [priority, setPriority] = useState("");
|
|
const [assigneeId, setAssigneeId] = useState("");
|
|
const [projectId, setProjectId] = useState("");
|
|
const [expanded, setExpanded] = useState(false);
|
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Popover states
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const [priorityOpen, setPriorityOpen] = useState(false);
|
|
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
|
const [projectOpen, setProjectOpen] = useState(false);
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && newIssueOpen,
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && newIssueOpen,
|
|
});
|
|
|
|
const createIssue = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) =>
|
|
issuesApi.create(selectedCompanyId!, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
clearDraft();
|
|
reset();
|
|
closeNewIssue();
|
|
},
|
|
});
|
|
|
|
// Debounced draft saving
|
|
const scheduleSave = useCallback(
|
|
(draft: IssueDraft) => {
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
draftTimer.current = setTimeout(() => {
|
|
if (draft.title.trim()) saveDraft(draft);
|
|
}, DEBOUNCE_MS);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Save draft on meaningful changes
|
|
useEffect(() => {
|
|
if (!newIssueOpen) return;
|
|
scheduleSave({ title, description, status, priority, assigneeId, projectId });
|
|
}, [title, description, status, priority, assigneeId, projectId, newIssueOpen, scheduleSave]);
|
|
|
|
// Restore draft or apply defaults when dialog opens
|
|
useEffect(() => {
|
|
if (!newIssueOpen) return;
|
|
|
|
const draft = loadDraft();
|
|
if (draft && draft.title.trim()) {
|
|
setTitle(draft.title);
|
|
setDescription(draft.description);
|
|
setStatus(draft.status || "todo");
|
|
setPriority(draft.priority);
|
|
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
|
|
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
|
} else {
|
|
setStatus(newIssueDefaults.status ?? "todo");
|
|
setPriority(newIssueDefaults.priority ?? "");
|
|
setProjectId(newIssueDefaults.projectId ?? "");
|
|
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
|
}
|
|
}, [newIssueOpen, newIssueDefaults]);
|
|
|
|
// Cleanup timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
function reset() {
|
|
setTitle("");
|
|
setDescription("");
|
|
setStatus("todo");
|
|
setPriority("");
|
|
setAssigneeId("");
|
|
setProjectId("");
|
|
setExpanded(false);
|
|
}
|
|
|
|
function discardDraft() {
|
|
clearDraft();
|
|
reset();
|
|
closeNewIssue();
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!selectedCompanyId || !title.trim()) return;
|
|
createIssue.mutate({
|
|
title: title.trim(),
|
|
description: description.trim() || undefined,
|
|
status,
|
|
priority: priority || "medium",
|
|
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
|
...(projectId ? { projectId } : {}),
|
|
});
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}
|
|
|
|
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
|
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
|
const currentPriority = priorities.find((p) => p.value === priority);
|
|
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
|
const currentProject = (projects ?? []).find((p) => p.id === projectId);
|
|
|
|
return (
|
|
<Dialog
|
|
open={newIssueOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) closeNewIssue();
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className={cn(
|
|
"p-0 gap-0 flex flex-col",
|
|
expanded
|
|
? "sm:max-w-2xl h-[calc(100vh-6rem)] max-h-[calc(100vh-6rem)]"
|
|
: "sm:max-w-lg"
|
|
)}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{/* Header bar */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
{selectedCompany && (
|
|
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
|
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
|
</span>
|
|
)}
|
|
<span className="text-muted-foreground/60">›</span>
|
|
<span>New issue</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground"
|
|
onClick={() => closeNewIssue()}
|
|
>
|
|
<span className="text-lg leading-none">×</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="px-4 pt-4 pb-2 shrink-0">
|
|
<input
|
|
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
|
placeholder="Issue title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className={cn("px-4 pb-2", expanded ? "flex-1 min-h-0" : "")}>
|
|
<textarea
|
|
className={cn(
|
|
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
|
|
expanded ? "h-full" : "min-h-[60px]"
|
|
)}
|
|
placeholder="Add description..."
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Property chips bar */}
|
|
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap shrink-0">
|
|
{/* Status chip */}
|
|
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
|
|
{currentStatus.label}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-36 p-1" align="start">
|
|
{statuses.map((s) => (
|
|
<button
|
|
key={s.value}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
s.value === status && "bg-accent"
|
|
)}
|
|
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
|
|
>
|
|
<CircleDot className={cn("h-3 w-3", s.color)} />
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Priority chip */}
|
|
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
{currentPriority ? (
|
|
<>
|
|
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
|
|
{currentPriority.label}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Minus className="h-3 w-3 text-muted-foreground" />
|
|
Priority
|
|
</>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-36 p-1" align="start">
|
|
{priorities.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
p.value === priority && "bg-accent"
|
|
)}
|
|
onClick={() => { setPriority(p.value); setPriorityOpen(false); }}
|
|
>
|
|
<p.icon className={cn("h-3 w-3", p.color)} />
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Assignee chip */}
|
|
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
<User className="h-3 w-3 text-muted-foreground" />
|
|
{currentAssignee ? currentAssignee.name : "Assignee"}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="start">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
!assigneeId && "bg-accent"
|
|
)}
|
|
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
|
|
>
|
|
No assignee
|
|
</button>
|
|
{(agents ?? []).map((a) => (
|
|
<button
|
|
key={a.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
a.id === assigneeId && "bg-accent"
|
|
)}
|
|
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
|
>
|
|
{a.name}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Project chip */}
|
|
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
<Hexagon className="h-3 w-3 text-muted-foreground" />
|
|
{currentProject ? currentProject.name : "Project"}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="start">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
!projectId && "bg-accent"
|
|
)}
|
|
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
|
>
|
|
No project
|
|
</button>
|
|
{(projects ?? []).map((p) => (
|
|
<button
|
|
key={p.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
p.id === projectId && "bg-accent"
|
|
)}
|
|
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
|
>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Labels chip (placeholder) */}
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
|
<Tag className="h-3 w-3" />
|
|
Labels
|
|
</button>
|
|
|
|
{/* More (dates) */}
|
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
|
<MoreHorizontal className="h-3 w-3" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="start">
|
|
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
|
<Calendar className="h-3 w-3" />
|
|
Start date
|
|
</button>
|
|
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
|
<Calendar className="h-3 w-3" />
|
|
Due date
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground"
|
|
onClick={discardDraft}
|
|
disabled={!hasDraft && !loadDraft()}
|
|
>
|
|
Discard Draft
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={!title.trim() || createIssue.isPending}
|
|
onClick={handleSubmit}
|
|
>
|
|
{createIssue.isPending ? "Creating..." : "Create Issue"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|