168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import { useMemo, useState } from 'react';
|
||
|
|
import { Link } from 'react-router-dom';
|
||
|
|
|
||
|
|
import { Alert } from '@/components/ui/Alert';
|
||
|
|
import { Button } from '@/components/ui/Button';
|
||
|
|
import { Card } from '@/components/ui/Card';
|
||
|
|
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||
|
|
import { Tag } from '@/components/ui/Tag';
|
||
|
|
import { TextField } from '@/components/ui/TextField';
|
||
|
|
import { ApiError, apiGet } from '@/lib/api';
|
||
|
|
import { useAuth } from '@/lib/auth';
|
||
|
|
import {
|
||
|
|
MISSION_STATUS_ACCENT,
|
||
|
|
MISSION_STATUS_LABEL,
|
||
|
|
buildMissionQueryString,
|
||
|
|
missionKeys,
|
||
|
|
type MissionFilters,
|
||
|
|
type MissionListResponse,
|
||
|
|
type MissionStatus,
|
||
|
|
} from '@/lib/missions';
|
||
|
|
|
||
|
|
const STATUS_OPTIONS: Array<{ value: '' | MissionStatus; label: string }> = [
|
||
|
|
{ value: '', label: 'All statuses' },
|
||
|
|
{ value: 'draft', label: MISSION_STATUS_LABEL.draft },
|
||
|
|
{ value: 'in_progress', label: MISSION_STATUS_LABEL.in_progress },
|
||
|
|
{ value: 'completed', label: MISSION_STATUS_LABEL.completed },
|
||
|
|
{ value: 'archived', label: MISSION_STATUS_LABEL.archived },
|
||
|
|
];
|
||
|
|
|
||
|
|
function useMissions(filters: MissionFilters) {
|
||
|
|
return useQuery({
|
||
|
|
queryKey: missionKeys.list(filters),
|
||
|
|
queryFn: () =>
|
||
|
|
apiGet<MissionListResponse>(`/missions${buildMissionQueryString(filters)}`),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDateRange(start: string | null, end: string | null): string {
|
||
|
|
if (!start && !end) return '—';
|
||
|
|
if (start && end) return `${start} → ${end}`;
|
||
|
|
return start ?? end ?? '—';
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MissionsListPage() {
|
||
|
|
const { state } = useAuth();
|
||
|
|
const canCreate =
|
||
|
|
state.user?.is_admin || state.user?.permissions.includes('mission.create');
|
||
|
|
|
||
|
|
const [q, setQ] = useState('');
|
||
|
|
const [status, setStatus] = useState<'' | MissionStatus>('');
|
||
|
|
const [client, setClient] = useState('');
|
||
|
|
|
||
|
|
const filters = useMemo<MissionFilters>(
|
||
|
|
() => ({
|
||
|
|
q: q.trim() || undefined,
|
||
|
|
status: status || undefined,
|
||
|
|
client: client.trim() || undefined,
|
||
|
|
}),
|
||
|
|
[q, status, client],
|
||
|
|
);
|
||
|
|
|
||
|
|
const { data, error, isLoading } = useMissions(filters);
|
||
|
|
const apiErr = error instanceof ApiError ? error : null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section data-testid="missions-list">
|
||
|
|
<div className="flex items-baseline justify-between">
|
||
|
|
<SectionHeader prefix="Plan" highlight="Missions" accent="cyan" />
|
||
|
|
{canCreate && (
|
||
|
|
<Link to="/missions/new" data-testid="missions-new-link">
|
||
|
|
<Button accent="cyan">+ New mission</Button>
|
||
|
|
</Link>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Card className="mb-6">
|
||
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||
|
|
<TextField
|
||
|
|
label="Search"
|
||
|
|
placeholder="name or description"
|
||
|
|
value={q}
|
||
|
|
onChange={(e) => setQ(e.target.value)}
|
||
|
|
data-testid="missions-filter-q"
|
||
|
|
/>
|
||
|
|
<TextField
|
||
|
|
label="Client"
|
||
|
|
placeholder="acme corp"
|
||
|
|
value={client}
|
||
|
|
onChange={(e) => setClient(e.target.value)}
|
||
|
|
data-testid="missions-filter-client"
|
||
|
|
/>
|
||
|
|
<div className="flex flex-col gap-1">
|
||
|
|
<label className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
|
||
|
|
Status
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={status}
|
||
|
|
onChange={(e) => setStatus(e.target.value as '' | MissionStatus)}
|
||
|
|
data-testid="missions-filter-status"
|
||
|
|
className="bg-bg-card border border-border rounded px-3 py-2 font-mono text-xs text-text-bright"
|
||
|
|
>
|
||
|
|
{STATUS_OPTIONS.map((opt) => (
|
||
|
|
<option key={opt.value} value={opt.value}>
|
||
|
|
{opt.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{apiErr && (
|
||
|
|
<div data-testid="missions-error">
|
||
|
|
<Alert accent="red">{apiErr.message}</Alert>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{isLoading && (
|
||
|
|
<p className="font-mono text-xs text-text-dim">Loading missions…</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{data && data.items.length === 0 && !isLoading && (
|
||
|
|
<Card>
|
||
|
|
<p className="font-mono text-xs text-text-dim" data-testid="missions-empty">
|
||
|
|
No missions match the filters. {canCreate ? 'Create one to get started.' : ''}
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2" data-testid="missions-grid">
|
||
|
|
{data?.items.map((m) => {
|
||
|
|
const accent = MISSION_STATUS_ACCENT[m.status];
|
||
|
|
return (
|
||
|
|
<Link key={m.id} to={`/missions/${m.id}`} data-testid={`mission-card-${m.id}`}>
|
||
|
|
<Card
|
||
|
|
accent={accent}
|
||
|
|
title={m.name}
|
||
|
|
sub={m.client_target ?? 'No client'}
|
||
|
|
className="h-full"
|
||
|
|
>
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
|
||
|
|
<Tag accent="cyan">{m.scenarios_count} scenarios</Tag>
|
||
|
|
<Tag accent="purple">{m.tests_count} tests</Tag>
|
||
|
|
<Tag accent="teal">{m.members_count} members</Tag>
|
||
|
|
</div>
|
||
|
|
<p className="mt-3 font-mono text-2xs text-text-dim">
|
||
|
|
{formatDateRange(m.date_start, m.date_end)}
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{data && (
|
||
|
|
<p
|
||
|
|
className="mt-4 font-mono text-2xs text-text-dim"
|
||
|
|
data-testid="missions-total"
|
||
|
|
>
|
||
|
|
{data.total} mission{data.total === 1 ? '' : 's'} total
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|