Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit. The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
This commit is contained in:
+96
-23
@@ -4,6 +4,7 @@
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
|
||||
import { ThemeProvider } from '@/lib/theme';
|
||||
import { Shell } from '@/components/Shell';
|
||||
@@ -11,7 +12,8 @@ import { SignIn } from '@/pages/SignIn';
|
||||
import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { Register } from '@/pages/Register';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
@@ -230,14 +232,14 @@ function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={title}
|
||||
description="Not built yet. The schema supports it — this is the next screen to write."
|
||||
description="That page does not exist or may have moved. Use Search to return to a workspace."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Team() {
|
||||
usePageTitle('Team');
|
||||
const { data } = useQuery({
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['team'],
|
||||
queryFn: () =>
|
||||
get<
|
||||
@@ -251,30 +253,101 @@ function Team() {
|
||||
>('/api/team'),
|
||||
});
|
||||
|
||||
const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0);
|
||||
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Team</h1>
|
||||
<p className="mt-1 text-sm text-muted">Supply, demand and research.</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
See who can operate each side of the compute business and where ownership is thin.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
|
||||
>
|
||||
Manage access in Settings
|
||||
</Link>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(data ?? []).map((person) => (
|
||||
<div key={person.id} className="card min-w-0 p-4">
|
||||
<p className="font-medium">{person.name}</p>
|
||||
{person.title ? <p className="text-sm text-muted">{person.title}</p> : null}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{person.teams.map((t) => (
|
||||
<span
|
||||
key={t.team}
|
||||
className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent-fg"
|
||||
>
|
||||
{t.team}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
|
||||
{[
|
||||
['People', data?.length ?? 0],
|
||||
['Teams', representedTeams],
|
||||
['Assignments', assignments],
|
||||
].map(([label, value]) => (
|
||||
<Card key={label} className="p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
|
||||
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card>
|
||||
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{[0, 1, 2].map((key) => <Skeleton key={key} className="h-40 rounded-2xl" />)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && data?.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState title="No team members yet" description="Invite and assign the first operator from Settings." />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && data?.length ? (
|
||||
<section aria-labelledby="team-members-heading">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
|
||||
<span className="text-xs text-muted">Roles are enforced server-side</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.map((person) => {
|
||||
const initials = person.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('');
|
||||
return (
|
||||
<Card key={person.id} className="p-4 sm:p-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-11 border border-border">
|
||||
<AvatarFallback className="bg-accent-subtle text-sm font-semibold text-accent-fg">
|
||||
{initials || 'P'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{person.name}</p>
|
||||
<p className="truncate text-sm text-muted">{person.title || 'Team member'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{person.teams.map((membership) => (
|
||||
<Badge key={`${membership.team}:${membership.role}`} tone="accent">
|
||||
{membership.team} · {membership.role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{person.teams.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user