mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 10:46:26 +01:00
Replace the severity-based row accent with one canonical type→color map in lib/attention.ts, sourced from IssueThreadInteractionCard's palette (sky confirmations/questions/verdicts, violet plan approvals, rose failures, amber blocked/recovery/budget, neutral join requests). Severity is demoted to a small Critical/High badge only. AttentionQueueRow: - Whole collapsed header toggles expand (role=button, aria-expanded, Enter/Space); navigation only via the identifier link / Open button / links in the card. - Collapsed inline rows surface decision-verb buttons that expand the card. - Richer 2-line detail line from T1's structured detail block (questions, suggested tasks, failures, budget, plan/confirmation excerpts). - Project/workspace chips with click-to-filter into the toolbar filter state. - Image thumbnail stack (≤3 + "+n") when detail.images is non-empty. - Snooze presets (1h / 4h / tomorrow morning / next week) + custom date-time; Dismiss shows an ~8s undo toast that restores the row in place. Extends ToastAction with an onClick (undo) variant. Adds component tests for the color map, detail line and fold-click behavior, plus a Storybook showcase story. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
622 lines
21 KiB
TypeScript
622 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
|
|
import type { Agent, AttentionItem, AttentionProjectRef } from "@paperclipai/shared";
|
|
import { attentionApi } from "../api/attention";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { useInboxDismissals } from "../hooks/useInboxBadge";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import {
|
|
ATTENTION_GROUP_BY_OPTIONS,
|
|
ATTENTION_SORT_OPTIONS,
|
|
buildAttentionFilterOptions,
|
|
countActiveAttentionFilters,
|
|
defaultAttentionFilterState,
|
|
filterAttentionItems,
|
|
groupAttentionItems,
|
|
isInlineResolvable,
|
|
loadAttentionFilters,
|
|
loadAttentionGroupBy,
|
|
loadAttentionSortOrder,
|
|
loadCollapsedAttentionGroupKeys,
|
|
NO_GROUP_SENTINEL,
|
|
saveAttentionFilters,
|
|
saveAttentionGroupBy,
|
|
saveAttentionSortOrder,
|
|
saveCollapsedAttentionGroupKeys,
|
|
sortAttentionItems,
|
|
sourceMeta,
|
|
type AttentionFilterState,
|
|
type AttentionGroupBy,
|
|
type AttentionSortOrder,
|
|
} from "../lib/attention";
|
|
import { cn } from "../lib/utils";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
|
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
|
import { Button } from "../components/ui/button";
|
|
import { Checkbox } from "../components/ui/checkbox";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
|
|
|
|
const SEVERITY_LABELS: Record<string, string> = {
|
|
critical: "Critical",
|
|
high: "High",
|
|
medium: "Medium",
|
|
low: "Low",
|
|
};
|
|
|
|
export function WhatNeedsMe() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [autoExpandDone, setAutoExpandDone] = useState(false);
|
|
|
|
// Toolbar preferences (persisted to localStorage, Inbox pattern).
|
|
const [groupBy, setGroupBy] = useState<AttentionGroupBy>(() => loadAttentionGroupBy());
|
|
const [sortOrder, setSortOrder] = useState<AttentionSortOrder>(() => loadAttentionSortOrder());
|
|
const [filters, setFilters] = useState<AttentionFilterState>(() => defaultAttentionFilterState);
|
|
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => new Set());
|
|
const [snoozedOpen, setSnoozedOpen] = useState(false);
|
|
const [dismissedOpen, setDismissedOpen] = useState(false);
|
|
|
|
// Optimistic hide/restore. Reset whenever a fresh feed lands (server truth).
|
|
const [pendingHide, setPendingHide] = useState<Set<string>>(() => new Set());
|
|
const [pendingRestore, setPendingRestore] = useState<Set<string>>(() => new Set());
|
|
|
|
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
|
|
const { pushToast } = useToastActions();
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "What needs me" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
// Re-hydrate per-company preferences when the company changes.
|
|
useEffect(() => {
|
|
setFilters(loadAttentionFilters(selectedCompanyId));
|
|
setCollapsedGroupKeys(loadCollapsedAttentionGroupKeys(selectedCompanyId));
|
|
}, [selectedCompanyId]);
|
|
|
|
const {
|
|
data: feed,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
// Distinct from the sidebar badge's `queryKeys.attention` so dismissed rows
|
|
// (needed for the curtains) never inflate the badge count. Invalidating the
|
|
// `["attention", companyId]` prefix still cascades to this query.
|
|
queryKey: [...queryKeys.attention(selectedCompanyId!), "with-dismissed"],
|
|
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true }),
|
|
enabled: !!selectedCompanyId,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const agent of agents ?? []) map.set(agent.id, agent);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
// Reset optimistic state once the server sends a fresh snapshot.
|
|
useEffect(() => {
|
|
setPendingHide(new Set());
|
|
setPendingRestore(new Set());
|
|
}, [feed?.generatedAt]);
|
|
|
|
const allItems = useMemo(() => feed?.items ?? [], [feed]);
|
|
|
|
const isServerHidden = (item: AttentionItem) => item.dismissal != null && item.dismissal.isActive;
|
|
|
|
const activeItems = useMemo(
|
|
() => allItems.filter((item) => !isServerHidden(item) && !pendingHide.has(item.id)),
|
|
[allItems, pendingHide],
|
|
);
|
|
const snoozedItems = useMemo(
|
|
() =>
|
|
allItems.filter(
|
|
(item) =>
|
|
item.dismissal?.kind === "snooze" && item.dismissal.isActive && !pendingRestore.has(item.id),
|
|
),
|
|
[allItems, pendingRestore],
|
|
);
|
|
const dismissedItems = useMemo(
|
|
() =>
|
|
allItems.filter(
|
|
(item) =>
|
|
item.dismissal?.kind === "dismiss" && item.dismissal.isActive && !pendingRestore.has(item.id),
|
|
),
|
|
[allItems, pendingRestore],
|
|
);
|
|
|
|
const filterOptions = useMemo(() => buildAttentionFilterOptions(activeItems), [activeItems]);
|
|
|
|
// Filter → sort → group, all client-side so switching re-buckets without a refetch.
|
|
const groups = useMemo(() => {
|
|
const filtered = filterAttentionItems(activeItems, filters);
|
|
const sorted = sortAttentionItems(filtered, sortOrder);
|
|
return groupAttentionItems(sorted, groupBy);
|
|
}, [activeItems, filters, sortOrder, groupBy]);
|
|
|
|
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
|
|
|
|
// Auto-expand the topmost inline-capable decision, once.
|
|
useEffect(() => {
|
|
if (autoExpandDone || activeItems.length === 0) return;
|
|
const sorted = sortAttentionItems(activeItems, sortOrder);
|
|
const topInline = sorted.find((item) => isInlineResolvable(item));
|
|
if (topInline) setExpandedId(topInline.id);
|
|
setAutoExpandDone(true);
|
|
}, [activeItems, autoExpandDone, sortOrder]);
|
|
|
|
const updateGroupBy = (next: AttentionGroupBy) => {
|
|
setGroupBy(next);
|
|
saveAttentionGroupBy(next);
|
|
};
|
|
const updateSortOrder = (next: AttentionSortOrder) => {
|
|
setSortOrder(next);
|
|
saveAttentionSortOrder(next);
|
|
};
|
|
const updateFilters = (next: AttentionFilterState) => {
|
|
setFilters(next);
|
|
saveAttentionFilters(selectedCompanyId, next);
|
|
};
|
|
const toggleGroupCollapse = (key: string) => {
|
|
setCollapsedGroupKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
saveCollapsedAttentionGroupKeys(selectedCompanyId, next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleUndoDismiss = (item: AttentionItem) => {
|
|
setPendingHide((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(item.id);
|
|
return next;
|
|
});
|
|
restore(item.dismissalKey);
|
|
};
|
|
const handleDismiss = (item: AttentionItem) => {
|
|
setPendingHide((prev) => new Set(prev).add(item.id));
|
|
dismiss(item.dismissalKey);
|
|
if (expandedId === item.id) setExpandedId(null);
|
|
// ~8s undo window; restores the row in place via T1's DELETE endpoint.
|
|
pushToast({
|
|
id: `attention-dismiss-${item.id}`,
|
|
dedupeKey: `attention-dismiss-${item.dismissalKey}`,
|
|
title: "Dismissed",
|
|
body: item.subject.title ?? undefined,
|
|
tone: "info",
|
|
ttlMs: 8000,
|
|
action: { label: "Undo", onClick: () => handleUndoDismiss(item) },
|
|
});
|
|
};
|
|
const handleSnooze = (item: AttentionItem, snoozedUntil: string) => {
|
|
setPendingHide((prev) => new Set(prev).add(item.id));
|
|
snooze(item.dismissalKey, snoozedUntil);
|
|
if (expandedId === item.id) setExpandedId(null);
|
|
};
|
|
const handleRestore = (item: AttentionItem) => {
|
|
setPendingRestore((prev) => new Set(prev).add(item.id));
|
|
restore(item.dismissalKey);
|
|
};
|
|
const handleFilterProject = (project: AttentionProjectRef) => {
|
|
if (filters.projectIds.includes(project.id)) return;
|
|
updateFilters({ ...filters, projectIds: [...filters.projectIds, project.id] });
|
|
};
|
|
|
|
const activeFilterCount = countActiveAttentionFilters(filters);
|
|
|
|
if (!selectedCompanyId) {
|
|
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <PageSkeleton variant="approvals" />;
|
|
}
|
|
|
|
const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0;
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h1 className="text-xl font-bold">What needs me</h1>
|
|
<div className="flex items-center gap-2">
|
|
{visibleCount > 0 && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{visibleCount} {visibleCount === 1 ? "decision" : "decisions"}
|
|
</span>
|
|
)}
|
|
{/* Filter */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className={cn("h-8 w-8 shrink-0", activeFilterCount > 0 && "bg-accent")}
|
|
title="Filter"
|
|
aria-label="Filter"
|
|
>
|
|
<ListFilter className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-64 p-0">
|
|
<FilterMenu
|
|
options={filterOptions}
|
|
filters={filters}
|
|
onChange={updateFilters}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{/* Group by */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className={cn("h-8 w-8 shrink-0", groupBy !== "date" && "bg-accent")}
|
|
title="Group"
|
|
aria-label="Group"
|
|
>
|
|
<Layers className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-40 p-2">
|
|
<div className="space-y-0.5">
|
|
{ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
|
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => updateGroupBy(value)}
|
|
>
|
|
<span>{label}</span>
|
|
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{/* Sort */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
title="Sort"
|
|
aria-label="Sort"
|
|
>
|
|
<ArrowUpDown className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-44 p-2">
|
|
<div className="space-y-0.5">
|
|
{ATTENTION_SORT_OPTIONS.map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
|
sortOrder === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => updateSortOrder(value)}
|
|
>
|
|
<span>{label}</span>
|
|
{sortOrder === value ? <Check className="h-3.5 w-3.5" /> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
|
|
|
{!hasAnything ? (
|
|
<ZeroState />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{visibleCount === 0 ? (
|
|
<CaughtUpNote filtered={activeItems.length > 0} />
|
|
) : (
|
|
groups.map((group) => {
|
|
const collapsed = collapsedGroupKeys.has(group.key);
|
|
return (
|
|
<section key={group.key} className="space-y-2">
|
|
<IssueGroupHeader
|
|
label={group.label}
|
|
collapsible
|
|
collapsed={collapsed}
|
|
onToggle={() => toggleGroupCollapse(group.key)}
|
|
trailing={
|
|
<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>
|
|
}
|
|
/>
|
|
{!collapsed && (
|
|
<div className="space-y-2">
|
|
{group.items.map((item) => (
|
|
<AttentionQueueRow
|
|
key={item.id}
|
|
item={item}
|
|
companyId={selectedCompanyId}
|
|
expanded={expandedId === item.id}
|
|
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
|
|
onDismiss={handleDismiss}
|
|
onSnooze={handleSnooze}
|
|
onFilterProject={handleFilterProject}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{snoozedItems.length > 0 && (
|
|
<Curtain
|
|
label="Snoozed"
|
|
count={snoozedItems.length}
|
|
open={snoozedOpen}
|
|
onToggle={() => setSnoozedOpen((prev) => !prev)}
|
|
>
|
|
{snoozedItems.map((item) => (
|
|
<AttentionQueueRow
|
|
key={item.id}
|
|
item={item}
|
|
companyId={selectedCompanyId}
|
|
variant="hidden"
|
|
expanded={false}
|
|
onToggleExpand={() => {}}
|
|
onDismiss={handleDismiss}
|
|
onRestore={handleRestore}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
))}
|
|
</Curtain>
|
|
)}
|
|
|
|
{dismissedItems.length > 0 && (
|
|
<Curtain
|
|
label="Dismissed"
|
|
count={dismissedItems.length}
|
|
open={dismissedOpen}
|
|
onToggle={() => setDismissedOpen((prev) => !prev)}
|
|
>
|
|
{dismissedItems.map((item) => (
|
|
<AttentionQueueRow
|
|
key={item.id}
|
|
item={item}
|
|
companyId={selectedCompanyId}
|
|
variant="hidden"
|
|
expanded={false}
|
|
onToggleExpand={() => {}}
|
|
onDismiss={handleDismiss}
|
|
onRestore={handleRestore}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
))}
|
|
</Curtain>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterMenu({
|
|
options,
|
|
filters,
|
|
onChange,
|
|
}: {
|
|
options: ReturnType<typeof buildAttentionFilterOptions>;
|
|
filters: AttentionFilterState;
|
|
onChange: (next: AttentionFilterState) => void;
|
|
}) {
|
|
const toggle = (key: keyof AttentionFilterState, value: string) => {
|
|
const list = filters[key] as string[];
|
|
const nextList = list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
|
onChange({ ...filters, [key]: nextList });
|
|
};
|
|
const hasActive = countActiveAttentionFilters(filters) > 0;
|
|
|
|
return (
|
|
<div className="max-h-[70vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between px-3 py-2">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
|
|
{hasActive && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => onChange(defaultAttentionFilterState)}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{options.sourceKinds.length > 1 && (
|
|
<FilterSection title="Type">
|
|
{options.sourceKinds.map((kind) => (
|
|
<FilterRow
|
|
key={kind}
|
|
label={sourceMeta(kind).label}
|
|
checked={filters.sourceKinds.includes(kind)}
|
|
onToggle={() => toggle("sourceKinds", kind)}
|
|
/>
|
|
))}
|
|
</FilterSection>
|
|
)}
|
|
|
|
{options.severities.length > 1 && (
|
|
<FilterSection title="Severity">
|
|
{options.severities.map((severity) => (
|
|
<FilterRow
|
|
key={severity}
|
|
label={SEVERITY_LABELS[severity] ?? severity}
|
|
checked={filters.severities.includes(severity)}
|
|
onToggle={() => toggle("severities", severity)}
|
|
/>
|
|
))}
|
|
</FilterSection>
|
|
)}
|
|
|
|
{(options.projects.length > 0 || options.hasNoProject) && (
|
|
<FilterSection title="Project">
|
|
{options.projects.map((project) => (
|
|
<FilterRow
|
|
key={project.id}
|
|
label={project.name}
|
|
checked={filters.projectIds.includes(project.id)}
|
|
onToggle={() => toggle("projectIds", project.id)}
|
|
/>
|
|
))}
|
|
{options.hasNoProject && (
|
|
<FilterRow
|
|
label="No project"
|
|
checked={filters.projectIds.includes(NO_GROUP_SENTINEL)}
|
|
onToggle={() => toggle("projectIds", NO_GROUP_SENTINEL)}
|
|
/>
|
|
)}
|
|
</FilterSection>
|
|
)}
|
|
|
|
{(options.workspaces.length > 0 || options.hasNoWorkspace) && (
|
|
<FilterSection title="Workspace">
|
|
{options.workspaces.map((workspace) => (
|
|
<FilterRow
|
|
key={workspace.id}
|
|
label={workspace.name}
|
|
checked={filters.workspaceIds.includes(workspace.id)}
|
|
onToggle={() => toggle("workspaceIds", workspace.id)}
|
|
/>
|
|
))}
|
|
{options.hasNoWorkspace && (
|
|
<FilterRow
|
|
label="No workspace"
|
|
checked={filters.workspaceIds.includes(NO_GROUP_SENTINEL)}
|
|
onToggle={() => toggle("workspaceIds", NO_GROUP_SENTINEL)}
|
|
/>
|
|
)}
|
|
</FilterSection>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterSection({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<div className="border-t border-border/60 px-2 py-1.5">
|
|
<p className="px-1 pb-1 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
|
{title}
|
|
</p>
|
|
<div className="space-y-0.5">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterRow({
|
|
label,
|
|
checked,
|
|
onToggle,
|
|
}: {
|
|
label: string;
|
|
checked: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded-sm px-1 py-1 text-left text-sm hover:bg-accent/50"
|
|
onClick={onToggle}
|
|
>
|
|
<Checkbox checked={checked} className="pointer-events-none" tabIndex={-1} />
|
|
<span className="truncate">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Curtain({
|
|
label,
|
|
count,
|
|
open,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
count: number;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="space-y-2">
|
|
<IssueGroupHeader
|
|
label={`${label} (${count})`}
|
|
collapsible
|
|
collapsed={!open}
|
|
onToggle={onToggle}
|
|
className="text-muted-foreground"
|
|
/>
|
|
{open && <div className="space-y-2">{children}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CaughtUpNote({ filtered }: { filtered: boolean }) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed border-border py-10 text-center">
|
|
<p className="text-sm font-medium text-foreground">
|
|
{filtered ? "No decisions match your filters." : "You're all caught up."}
|
|
</p>
|
|
{filtered && (
|
|
<p className="mt-1 text-xs text-muted-foreground">Adjust or clear the filters to see the rest.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ZeroState() {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
|
<div className="mb-4 rounded-full bg-green-500/10 p-4">
|
|
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
|
</div>
|
|
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
|
|
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Inbox className="h-4 w-4" />
|
|
Nothing needs a decision from you right now.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|