SPB Git

Repository Public

page.tsx 5.4 KB 102 lines tsx

1'use client';2import { useMemo, useState } from 'react';3import { Plus, Search } from 'lucide-react';4import { useMissions, useModels, useProjects } from '@/lib/queries';5import { useUi } from '@/stores/ui';6import { isActive } from '@/lib/status';7import { Button } from '@/components/ui/button';8import { FilterChips } from '@/components/ui/chips';9import { Input } from '@/components/ui/input';10import { NONE, Select } from '@/components/ui/select';11import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';12import { IllusMission } from '@/components/ui/illustrations';13import { MissionRow } from '@/components/missions/mission-card';1415type Filter = 'all' | 'active' | 'waiting' | 'finished' | 'failed';1617export default function MissionsPage() {18  const q = useMissions();19  const models = useModels().data ?? [];20  const projects = useProjects().data ?? [];21  const openNew = useUi((s) => s.openNewMission);22  const [filter, setFilter] = useState<Filter>('all');23  const [project, setProject] = useState<string>(NONE);24  const [text, setText] = useState('');25  const all = q.data ?? [];26  const scoped = useMemo(() => {27    const t = text.trim().toLowerCase();28    return all.filter((m) => (project === NONE || m.projectId === project) && (!t || m.title.toLowerCase().includes(t) || m.objective.toLowerCase().includes(t)));29  }, [all, project, text]);30  const match: Record<Filter, (s: (typeof all)[number]) => boolean> = {31    all: () => true,32    active: (m) => isActive(m.status),33    waiting: (m) => m.status === 'waiting',34    finished: (m) => m.status === 'done' || m.status === 'partial',35    failed: (m) => ['failed', 'blocked', 'cancelled', 'budget_exhausted'].includes(m.status),36  };37  const list = scoped.filter(match[filter]);38  const count = (f: Filter) => scoped.filter(match[f]).length;3940  return (41    <div className="flex h-full flex-col" data-testid="missions-page">42      <div className="shrink-0 px-4 pb-3 pt-1 md:px-8 md:pt-6">43        <div className="mx-auto flex w-full max-w-5xl flex-col gap-3">44          <div className="flex items-center gap-3">45            <div className="hidden min-w-0 flex-1 md:block">46              <h1 className="text-xl font-bold tracking-tight">Missions</h1>47              <p className="text-[13px] text-text-2">Every objective given to an agent, with its status and what it cost.</p>48            </div>49            <div className="relative flex-1 md:w-64 md:flex-none">50              <Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted" aria-hidden />51              <Input value={text} onChange={(e) => setText(e.target.value)} placeholder="Search missions" aria-label="Search missions" className="pl-8 pointer-coarse:pl-9" data-testid="missions-search" />52            </div>53            <Button variant="brand" className="h-8 shrink-0 rounded-full px-3.5 font-semibold pointer-coarse:h-12 pointer-coarse:px-4 md:hidden" onClick={() => openNew()} data-testid="new-mission-page" aria-label="New mission">54              <Plus className="size-4" strokeWidth={2.6} /> New55            </Button>56          </div>57          <div className="flex flex-wrap items-center gap-x-4 gap-y-2">58            <div className="min-w-0 flex-1">59              <FilterChips60                label="Status filter"61                value={filter}62                onChange={setFilter}63                options={[64                  { id: 'all', label: 'All', count: count('all') },65                  { id: 'active', label: 'Active', count: count('active') },66                  { id: 'waiting', label: 'Needs you', count: count('waiting') },67                  { id: 'finished', label: 'Finished', count: count('finished') },68                  { id: 'failed', label: 'Stopped', count: count('failed') },69                ]}70              />71            </div>72            {projects.length > 1 && (73              <Select value={project} onValueChange={setProject} label="Project filter" className="w-full sm:w-52" options={[{ value: NONE, label: 'All projects' }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />74            )}75          </div>76        </div>77      </div>78      <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-32 md:px-8 md:pb-10">79        <div className="mx-auto w-full max-w-5xl">80          {q.isError ? (81            <div className="card"><ErrorState error={q.error} onRetry={() => q.refetch()} /></div>82          ) : q.isPending ? (83            <div className="flex flex-col gap-2"><Skeleton className="h-14 rounded-md" /><Skeleton className="h-14 rounded-md" /><Skeleton className="h-14 rounded-md" /></div>84          ) : list.length === 0 ? (85            <div className="card">86              {all.length === 0 ? (87                <EmptyState illustration={<IllusMission />} title="No mission yet" description="A mission is an objective, a project and a budget. Start the first one." action={<Button variant="brand" className="h-9 rounded-full px-5 font-semibold pointer-coarse:h-12" onClick={() => openNew()}><Plus className="size-4" strokeWidth={2.6} /> New mission</Button>} />88              ) : (89                <EmptyState title="No mission matches" description="Change the filters or the search text." />90              )}91            </div>92          ) : (93            <div className="card divide-y divide-line overflow-hidden" data-testid="missions-list">94              {list.map((m) => <MissionRow key={m.id} mission={m} models={models} />)}95            </div>96          )}97        </div>98      </div>99    </div>100  );101}102
Files main
Menu

Theme