All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactElement, SyntheticEvent } from "react";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams";
|
|
|
|
function getErrorMessage(error: unknown, fallback: string): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
export default function TeamsSettingsPage(): ReactElement {
|
|
const [teams, setTeams] = useState<TeamRecord[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [newTeamName, setNewTeamName] = useState("");
|
|
const [newTeamDescription, setNewTeamDescription] = useState("");
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
const loadTeams = useCallback(async (): Promise<void> => {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const data = await fetchTeams();
|
|
setTeams(data);
|
|
setLoadError(null);
|
|
} catch (error) {
|
|
setLoadError(getErrorMessage(error, "Failed to load teams"));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadTeams();
|
|
}, [loadTeams]);
|
|
|
|
const handleCreateTeam = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
|
|
e.preventDefault();
|
|
|
|
const teamName = newTeamName.trim();
|
|
if (!teamName) return;
|
|
|
|
setIsCreating(true);
|
|
setCreateError(null);
|
|
|
|
try {
|
|
const description = newTeamDescription.trim();
|
|
const dto: CreateTeamDto = { name: teamName };
|
|
if (description.length > 0) {
|
|
dto.description = description;
|
|
}
|
|
|
|
await createTeam(dto);
|
|
setNewTeamName("");
|
|
setNewTeamDescription("");
|
|
setIsCreateDialogOpen(false);
|
|
await loadTeams();
|
|
} catch (error) {
|
|
setCreateError(getErrorMessage(error, "Failed to create team"));
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h1 className="text-3xl font-bold text-gray-900">Teams</h1>
|
|
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
|
|
{"<-"} Back to Settings
|
|
</Link>
|
|
</div>
|
|
<p className="text-gray-600">Manage teams in your active workspace</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">Create New Team</h2>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
Add a team to organize members and permissions.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCreateError(null);
|
|
setIsCreateDialogOpen(true);
|
|
}}
|
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
|
>
|
|
Create Team
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{isCreateDialogOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
|
role="dialog"
|
|
>
|
|
<div className="w-full max-w-lg rounded-lg border border-gray-200 bg-white p-6 shadow-xl">
|
|
<h3 className="text-lg font-semibold text-gray-900">Create New Team</h3>
|
|
<p className="mt-1 text-sm text-gray-600">
|
|
Enter a team name and optional description.
|
|
</p>
|
|
|
|
<form onSubmit={handleCreateTeam} className="mt-4 space-y-4">
|
|
<div>
|
|
<label htmlFor="team-name" className="mb-1 block text-sm font-medium text-gray-700">
|
|
Team Name
|
|
</label>
|
|
<input
|
|
id="team-name"
|
|
type="text"
|
|
value={newTeamName}
|
|
onChange={(e) => {
|
|
setNewTeamName(e.target.value);
|
|
}}
|
|
placeholder="Enter team name..."
|
|
disabled={isCreating}
|
|
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="team-description"
|
|
className="mb-1 block text-sm font-medium text-gray-700"
|
|
>
|
|
Description (optional)
|
|
</label>
|
|
<textarea
|
|
id="team-description"
|
|
value={newTeamDescription}
|
|
onChange={(e) => {
|
|
setNewTeamDescription(e.target.value);
|
|
}}
|
|
placeholder="Describe this team's purpose..."
|
|
disabled={isCreating}
|
|
rows={3}
|
|
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
|
/>
|
|
</div>
|
|
|
|
{createError !== null && (
|
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{createError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!isCreating) {
|
|
setIsCreateDialogOpen(false);
|
|
}
|
|
}}
|
|
disabled={isCreating}
|
|
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isCreating || !newTeamName.trim()}
|
|
className="px-5 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{isCreating ? "Creating..." : "Create Team"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<h2 className="text-xl font-semibold text-gray-900">
|
|
Your Teams ({isLoading ? "..." : teams.length})
|
|
</h2>
|
|
{loadError !== null ? (
|
|
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
|
|
{loadError}
|
|
</div>
|
|
) : isLoading ? (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
|
|
Loading teams...
|
|
</div>
|
|
) : teams.length === 0 ? (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
|
<svg
|
|
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M17 20h5V8H2v12h5m10 0v-4a3 3 0 10-6 0v4m6 0H7"
|
|
/>
|
|
</svg>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No teams yet</h3>
|
|
<p className="text-gray-600">Create your first team to get started</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{teams.map((team) => (
|
|
<article
|
|
key={team.id}
|
|
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"
|
|
data-testid="team-card"
|
|
>
|
|
<h3 className="text-lg font-semibold text-gray-900">{team.name}</h3>
|
|
{team.description ? (
|
|
<p className="mt-1 text-sm text-gray-600">{team.description}</p>
|
|
) : (
|
|
<p className="mt-1 text-sm text-gray-400 italic">No description</p>
|
|
)}
|
|
<div className="mt-4 flex items-center gap-3 text-xs text-gray-500">
|
|
<span>{team._count?.members ?? 0} members</span>
|
|
<span>|</span>
|
|
<span>Created {new Date(team.createdAt).toLocaleDateString()}</span>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|