|
|
@@ -2,38 +2,32 @@ import * as React from "react";
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
import {
|
|
|
Activity,
|
|
|
- Archive,
|
|
|
Bot,
|
|
|
CheckCircle2,
|
|
|
- Copy,
|
|
|
- FileCode2,
|
|
|
- Network,
|
|
|
- Play,
|
|
|
+ Clock,
|
|
|
RefreshCw,
|
|
|
Search,
|
|
|
SlidersHorizontal,
|
|
|
Users,
|
|
|
} from "lucide-react";
|
|
|
-import { listTeamRuns, listTeamVersions, listTeams } from "@/api";
|
|
|
+import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
|
|
|
import { ApiErrorState } from "@/components/shared/ApiErrorState";
|
|
|
import { EmptyState } from "@/components/shared/EmptyState";
|
|
|
-import { EntityListItem } from "@/components/shared/EntityListItem";
|
|
|
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
|
|
import { MetricCard } from "@/components/shared/MetricCard";
|
|
|
import { PageHeader } from "@/components/shared/PageHeader";
|
|
|
import { SearchInput } from "@/components/shared/SearchInput";
|
|
|
import { StatusBadge } from "@/components/shared/StatusBadge";
|
|
|
+import { Badge } from "@/components/ui/badge";
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
import { Select } from "@/components/ui/select";
|
|
|
-import { Tabs } from "@/components/ui/tabs";
|
|
|
import { toast } from "@/components/ui/toaster";
|
|
|
-import { copyToClipboard } from "@/lib/utils";
|
|
|
-import type { TeamDefinition, TeamRun, TeamStatus, TeamVersion } from "@/types";
|
|
|
+import { cn, relativeTime } from "@/lib/utils";
|
|
|
+import type { TeamConfig, TeamDefinition, TeamRun, TeamStatus } from "@/types";
|
|
|
import { CreateTeamDialog } from "./components/CreateTeamDialog";
|
|
|
import { TeamOverview } from "./components/TeamOverview";
|
|
|
import { TeamRuns } from "./components/TeamRuns";
|
|
|
-import { TeamVersions } from "./components/TeamVersions";
|
|
|
|
|
|
type StatusFilter = "all" | TeamStatus;
|
|
|
type SortMode = "recent" | "name" | "status";
|
|
|
@@ -41,35 +35,34 @@ type SortMode = "recent" | "name" | "status";
|
|
|
export function TeamsPage() {
|
|
|
const { t } = useTranslation();
|
|
|
const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
|
|
|
- const [versions, setVersions] = React.useState<TeamVersion[]>([]);
|
|
|
+ const [configs, setConfigs] = React.useState<TeamConfig[]>([]);
|
|
|
const [runs, setRuns] = React.useState<TeamRun[]>([]);
|
|
|
const [selectedTeamId, setSelectedTeamId] = React.useState<string>();
|
|
|
const [search, setSearch] = React.useState("");
|
|
|
const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
|
|
|
const [sortMode, setSortMode] = React.useState<SortMode>("recent");
|
|
|
- const [activeTab, setActiveTab] = React.useState("overview");
|
|
|
const [loading, setLoading] = React.useState(true);
|
|
|
const [error, setError] = React.useState<string>();
|
|
|
const [relatedLoading, setRelatedLoading] = React.useState(false);
|
|
|
const [createOpen, setCreateOpen] = React.useState(false);
|
|
|
|
|
|
const selectedTeam = teams.find((team) => team.id === selectedTeamId);
|
|
|
- const sortedVersions = React.useMemo(
|
|
|
- () => [...versions].sort((a, b) => b.version_no - a.version_no),
|
|
|
- [versions],
|
|
|
+ const sortedConfigs = React.useMemo(
|
|
|
+ () => [...configs].sort((a, b) => b.version_no - a.version_no),
|
|
|
+ [configs],
|
|
|
);
|
|
|
const sortedRuns = React.useMemo(
|
|
|
() => [...runs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime()),
|
|
|
[runs],
|
|
|
);
|
|
|
- const latestVersion = sortedVersions[0];
|
|
|
+ const activeConfig = sortedConfigs[0];
|
|
|
const failedRunCount = sortedRuns.filter((run) => run.status === "failed").length;
|
|
|
const activeTeams = teams.filter((team) => team.status === "active").length;
|
|
|
const draftTeams = teams.filter((team) => team.status === "draft").length;
|
|
|
|
|
|
const filtered = teams
|
|
|
.filter((team) => {
|
|
|
- const text = `${team.name} ${team.code} ${team.team_type} ${team.description ?? ""}`.toLowerCase();
|
|
|
+ const text = `${team.name} ${team.team_type} ${team.description ?? ""}`.toLowerCase();
|
|
|
const matchesSearch = text.includes(search.toLowerCase());
|
|
|
const matchesStatus = statusFilter === "all" || team.status === statusFilter;
|
|
|
return matchesSearch && matchesStatus;
|
|
|
@@ -99,11 +92,11 @@ export function TeamsPage() {
|
|
|
const reloadDetails = React.useCallback(async () => {
|
|
|
if (!selectedTeamId) return;
|
|
|
setRelatedLoading(true);
|
|
|
- setVersions([]);
|
|
|
+ setConfigs([]);
|
|
|
setRuns([]);
|
|
|
try {
|
|
|
- const [versionData, runData] = await Promise.all([listTeamVersions(selectedTeamId), listTeamRuns(selectedTeamId)]);
|
|
|
- setVersions(versionData);
|
|
|
+ const [configData, runData] = await Promise.all([listTeamConfigs(selectedTeamId), listTeamRuns(selectedTeamId)]);
|
|
|
+ setConfigs(configData);
|
|
|
setRuns(runData);
|
|
|
} catch {
|
|
|
toast.error(t("errors.failedToLoad"));
|
|
|
@@ -122,12 +115,6 @@ export function TeamsPage() {
|
|
|
setSortMode("recent");
|
|
|
}
|
|
|
|
|
|
- async function copyTeamCode() {
|
|
|
- if (!selectedTeam) return;
|
|
|
- await copyToClipboard(selectedTeam.code);
|
|
|
- toast.success(t("teams.teamCodeCopied"));
|
|
|
- }
|
|
|
-
|
|
|
if (loading) return <LoadingSpinner label={t("common.loading")} />;
|
|
|
if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
|
|
|
|
|
|
@@ -148,29 +135,28 @@ export function TeamsPage() {
|
|
|
}
|
|
|
/>
|
|
|
|
|
|
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
|
|
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
|
<MetricCard label={t("teams.title")} value={teams.length} icon={Users} />
|
|
|
<MetricCard label={t("common.active")} value={activeTeams} icon={CheckCircle2} />
|
|
|
- <MetricCard label={t("common.draft")} value={draftTeams} icon={Archive} />
|
|
|
- <MetricCard label={t("common.versions")} value={versions.length} icon={Network} />
|
|
|
+ <MetricCard label={t("common.runs")} value={sortedRuns.length} icon={Activity} />
|
|
|
<MetricCard label={t("teams.failedRuns")} value={failedRunCount} icon={Activity} />
|
|
|
</div>
|
|
|
|
|
|
- <div className="grid gap-6 xl:grid-cols-[440px_1fr]">
|
|
|
- <Card>
|
|
|
+ <div className="grid gap-6 xl:grid-cols-[460px_1fr]">
|
|
|
+ <Card className="overflow-hidden">
|
|
|
<CardHeader>
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
<div>
|
|
|
<CardTitle>{t("teams.teamDirectory")}</CardTitle>
|
|
|
<CardDescription>
|
|
|
- {t("teams.teamsShown", { count: teams.length })} {filtered.length}
|
|
|
+ {filtered.length} / {teams.length} - {draftTeams} {t("common.draft")}
|
|
|
</CardDescription>
|
|
|
</div>
|
|
|
<SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
|
|
|
</div>
|
|
|
</CardHeader>
|
|
|
<CardContent className="space-y-4">
|
|
|
- <SearchInput value={search} onChange={setSearch} placeholder={t("teams.searchByNameCodeType")} />
|
|
|
+ <SearchInput value={search} onChange={setSearch} placeholder="Search by name, type, or description" />
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
<Select
|
|
|
aria-label={t("teams.filterByStatus")}
|
|
|
@@ -201,15 +187,14 @@ export function TeamsPage() {
|
|
|
) : null}
|
|
|
|
|
|
{filtered.length ? (
|
|
|
- <div className="space-y-2">
|
|
|
+ <div className="space-y-3">
|
|
|
{filtered.map((team) => (
|
|
|
- <EntityListItem
|
|
|
+ <TeamDirectoryItem
|
|
|
key={team.id}
|
|
|
+ team={team}
|
|
|
active={team.id === selectedTeamId}
|
|
|
- title={team.name}
|
|
|
- subtitle={`${readableLabel(team.team_type)} - ${team.code}`}
|
|
|
- meta={<StatusBadge status={team.status} />}
|
|
|
- onClick={() => { setSelectedTeamId(team.id); setActiveTab("overview"); }}
|
|
|
+ t={t}
|
|
|
+ onClick={() => setSelectedTeamId(team.id)}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
@@ -219,73 +204,45 @@ export function TeamsPage() {
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
+ <Card className="min-w-0 overflow-hidden">
|
|
|
+ <CardHeader className="border-b border-border bg-muted/15 p-5">
|
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
|
<div className="min-w-0">
|
|
|
- <div className="flex flex-wrap items-center gap-2">
|
|
|
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
|
<CardTitle className="truncate text-lg">{selectedTeam?.name ?? t("teams.teamCockpit")}</CardTitle>
|
|
|
{selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
|
|
|
</div>
|
|
|
<CardDescription className="mt-1">
|
|
|
- {selectedTeam ? `${readableLabel(selectedTeam.team_type)} - ${selectedTeam.code}` : t("teams.selectTeamInspect")}
|
|
|
+ {selectedTeam ? readableLabel(selectedTeam.team_type) : t("teams.selectTeamInspect")}
|
|
|
</CardDescription>
|
|
|
</div>
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
- <Button variant="outline" size="sm" disabled={!selectedTeam} onClick={() => void copyTeamCode()}>
|
|
|
- <Copy className="h-4 w-4" /> {t("teams.copyCode")}
|
|
|
- </Button>
|
|
|
- <Button variant="secondary" size="sm" disabled={!selectedTeam} onClick={() => setCreateOpen(true)}>
|
|
|
- <FileCode2 className="h-4 w-4" /> {t("teams.newVersion")}
|
|
|
- </Button>
|
|
|
- {selectedTeam && latestVersion ? (
|
|
|
- <Button size="sm" onClick={() => setActiveTab("runs")}>
|
|
|
- <Play className="h-4 w-4" /> {t("teams.run")}
|
|
|
- </Button>
|
|
|
+ {selectedTeam && activeConfig ? (
|
|
|
+ <Badge className="border-border bg-surface-elevated text-muted-foreground">
|
|
|
+ {sortedRuns.length} {t("common.runs")}
|
|
|
+ </Badge>
|
|
|
) : null}
|
|
|
</div>
|
|
|
</div>
|
|
|
</CardHeader>
|
|
|
- <CardContent>
|
|
|
+ <CardContent className="p-4">
|
|
|
{selectedTeam ? (
|
|
|
- <Tabs
|
|
|
- value={activeTab}
|
|
|
- onChange={setActiveTab}
|
|
|
- tabs={[
|
|
|
- {
|
|
|
- value: "overview",
|
|
|
- label: t("common.overview"),
|
|
|
- content: (
|
|
|
- <TeamOverview
|
|
|
- team={selectedTeam}
|
|
|
- latestVersion={latestVersion}
|
|
|
- versionCount={versions.length}
|
|
|
- runCount={sortedRuns.length}
|
|
|
- failedRunCount={failedRunCount}
|
|
|
- latestRun={sortedRuns[0]}
|
|
|
- />
|
|
|
- ),
|
|
|
- },
|
|
|
- {
|
|
|
- value: "runs",
|
|
|
- label: t("common.runs"),
|
|
|
- content: (
|
|
|
- <TeamRuns
|
|
|
- teamId={selectedTeam.id}
|
|
|
- versions={sortedVersions}
|
|
|
- runs={sortedRuns}
|
|
|
- loading={relatedLoading}
|
|
|
- onRunCreated={(run) => { setRuns((current) => [run, ...current]); void reloadDetails(); }}
|
|
|
- />
|
|
|
- ),
|
|
|
- },
|
|
|
- {
|
|
|
- value: "versions",
|
|
|
- label: t("common.versions"),
|
|
|
- content: <TeamVersions versions={versions} loading={relatedLoading} />,
|
|
|
- },
|
|
|
- ]}
|
|
|
- />
|
|
|
+ <div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1fr)_380px]">
|
|
|
+ <TeamOverview
|
|
|
+ team={selectedTeam}
|
|
|
+ activeConfig={activeConfig}
|
|
|
+ runCount={sortedRuns.length}
|
|
|
+ failedRunCount={failedRunCount}
|
|
|
+ latestRun={sortedRuns[0]}
|
|
|
+ />
|
|
|
+ <TeamRuns
|
|
|
+ teamId={selectedTeam.id}
|
|
|
+ configs={sortedConfigs}
|
|
|
+ runs={sortedRuns}
|
|
|
+ loading={relatedLoading}
|
|
|
+ onRunCreated={(run) => { setRuns((current) => [run, ...current]); void reloadDetails(); }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
) : (
|
|
|
<EmptyState icon={Bot} title={t("teams.noTeams")} description={t("teams.createTeamStart")} actionLabel={t("teams.newTeam")} onAction={() => setCreateOpen(true)} />
|
|
|
)}
|
|
|
@@ -306,6 +263,49 @@ export function TeamsPage() {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+function TeamDirectoryItem({
|
|
|
+ team,
|
|
|
+ active,
|
|
|
+ t,
|
|
|
+ onClick,
|
|
|
+}: {
|
|
|
+ team: TeamDefinition;
|
|
|
+ active?: boolean;
|
|
|
+ t: (key: string, options?: Record<string, unknown>) => string;
|
|
|
+ onClick: () => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onClick}
|
|
|
+ className={cn(
|
|
|
+ "w-full rounded-md border border-border bg-muted/30 p-4 text-left transition hover:bg-muted/55 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
|
+ active && "border-primary/45 bg-primary/10 shadow-glow",
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ <div className="flex items-start justify-between gap-3">
|
|
|
+ <div className="min-w-0">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Users className="h-4 w-4 text-primary" />
|
|
|
+ <p className="truncate text-sm font-semibold">{team.name}</p>
|
|
|
+ </div>
|
|
|
+ <p className="mt-1 truncate text-xs text-muted-foreground">{readableLabel(team.team_type)}</p>
|
|
|
+ </div>
|
|
|
+ <StatusBadge status={team.status} />
|
|
|
+ </div>
|
|
|
+ <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
|
|
+ {team.description ?? t("teams.noDescription")}
|
|
|
+ </p>
|
|
|
+ <div className="mt-3 flex flex-wrap items-center gap-2">
|
|
|
+ <Badge className="border-border bg-surface-elevated text-muted-foreground">{readableLabel(team.team_type)}</Badge>
|
|
|
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
+ <Clock className="h-3.5 w-3.5" /> {relativeTime(team.created_time)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function readableLabel(value: string) {
|
|
|
return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
|
|
}
|