feat(web): add teams settings page (MS21-UI-005) (#576)
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>
This commit was merged in pull request #576.
This commit is contained in:
2026-02-28 22:12:04 +00:00
committed by jason.woltje
parent 31814f181a
commit 307639eca0
6 changed files with 513 additions and 328 deletions

View File

@@ -2,136 +2,25 @@
import type { ReactElement } from "react";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { TeamSettings } from "@/components/team/TeamSettings";
import { TeamMemberList } from "@/components/team/TeamMemberList";
import { mockTeamWithMembers } from "@/lib/api/teams";
import type { User } from "@mosaic/shared";
import type { TeamMemberRole } from "@mosaic/shared";
import { useParams } from "next/navigation";
import { ComingSoon } from "@/components/ui/ComingSoon";
import Link from "next/link";
// Mock available users for adding to team
const mockAvailableUsers: User[] = [
{
id: "user-3",
email: "alice@example.com",
name: "Alice Johnson",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
createdAt: new Date("2026-01-17"),
updatedAt: new Date("2026-01-17"),
},
{
id: "user-4",
email: "bob@example.com",
name: "Bob Wilson",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
createdAt: new Date("2026-01-18"),
updatedAt: new Date("2026-01-18"),
},
];
export default function TeamDetailPage(): ReactElement {
const params = useParams();
const router = useRouter();
const workspaceId = params.id as string;
// const teamId = params.teamId as string; // Will be used for API calls
// TODO: Replace with real API call when backend is ready
// const { data: team, isLoading } = useQuery({
// queryKey: ["team", workspaceId, params.teamId],
// queryFn: () => fetchTeam(workspaceId, params.teamId as string),
// });
const [team] = useState(mockTeamWithMembers);
const [isLoading] = useState(false);
const handleUpdateTeam = (data: { name?: string; description?: string }): Promise<void> => {
// TODO: Replace with real API call
// await updateTeam(workspaceId, teamId, data);
console.log("Updating team:", data);
// TODO: Refetch team data
return Promise.resolve();
};
const handleDeleteTeam = (): Promise<void> => {
// TODO: Replace with real API call
// await deleteTeam(workspaceId, teamId);
console.log("Deleting team");
// Navigate back to teams list
router.push(`/settings/workspaces/${workspaceId}/teams`);
return Promise.resolve();
};
const handleAddMember = (userId: string, role?: TeamMemberRole): Promise<void> => {
// TODO: Replace with real API call
// await addTeamMember(workspaceId, teamId, { userId, role });
console.log("Adding member:", { userId, role });
// TODO: Refetch team data
return Promise.resolve();
};
const handleRemoveMember = (userId: string): Promise<void> => {
// TODO: Replace with real API call
// await removeTeamMember(workspaceId, teamId, userId);
console.log("Removing member:", userId);
// TODO: Refetch team data
return Promise.resolve();
};
if (isLoading) {
return (
<main className="container mx-auto px-4 py-8">
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading team...</span>
</div>
</main>
);
}
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<Link
href={`/settings/workspaces/${workspaceId}/teams`}
className="text-blue-600 hover:text-blue-700 text-sm mb-2 inline-block"
>
Back to Teams
</Link>
<h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
{team.description && <p className="text-gray-600 mt-2">{team.description}</p>}
</div>
<div className="space-y-6">
<TeamSettings team={team} onUpdate={handleUpdateTeam} onDelete={handleDeleteTeam} />
<TeamMemberList
members={team.members}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
availableUsers={mockAvailableUsers}
/>
</div>
</main>
<ComingSoon
feature="Team Details"
description="Team member management is being migrated to live API-backed data."
>
<Link
href={`/settings/workspaces/${workspaceId}/teams`}
className="text-sm text-blue-600 hover:text-blue-700"
>
{"<-"} Back to Teams
</Link>
</ComingSoon>
);
}

View File

@@ -2,63 +2,90 @@
import type { ReactElement } from "react";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { TeamCard } from "@/components/team/TeamCard";
import { ComingSoon } from "@/components/ui/ComingSoon";
import { Button, Input, Modal } from "@mosaic/ui";
import { mockTeams } from "@/lib/api/teams";
import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams";
import Link from "next/link";
// Check if we're in development mode
const isDevelopment = process.env.NODE_ENV === "development";
/**
* Teams Page Content - Development Only
* Shows mock team data for development purposes
*/
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
return fallback;
}
function TeamsPageContent(): ReactElement {
const params = useParams();
const workspaceId = params.id as string;
// TODO: Replace with real API call when backend is ready
// const { data: teams, isLoading } = useQuery({
// queryKey: ["teams", workspaceId],
// queryFn: () => fetchTeams(workspaceId),
// });
const [teams] = useState(mockTeams);
const [isLoading] = useState(false);
const [teams, setTeams] = useState<TeamRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newTeamName, setNewTeamName] = useState("");
const [newTeamDescription, setNewTeamDescription] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const handleCreateTeam = (): void => {
if (!newTeamName.trim()) return;
const loadTeams = useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const data = await fetchTeams(workspaceId);
setTeams(data);
setLoadError(null);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load teams"));
} finally {
setIsLoading(false);
}
}, [workspaceId]);
useEffect(() => {
void loadTeams();
}, [loadTeams]);
const handleCreateTeam = async (): Promise<void> => {
const teamName = newTeamName.trim();
if (!teamName) return;
setIsCreating(true);
try {
// TODO: Replace with real API call
// await createTeam(workspaceId, {
// name: newTeamName,
// description: newTeamDescription || undefined,
// });
setCreateError(null);
try {
const description = newTeamDescription.trim();
const dto: CreateTeamDto = {
name: teamName,
};
if (description.length > 0) {
dto.description = description;
}
await createTeam(dto, workspaceId);
// Reset form
setNewTeamName("");
setNewTeamDescription("");
setShowCreateModal(false);
// TODO: Refresh teams list
} catch (_error) {
console.error("Failed to create team:", _error);
alert("Failed to create team. Please try again.");
await loadTeams();
} catch (error) {
setCreateError(getErrorMessage(error, "Failed to create team"));
} finally {
setIsCreating(false);
}
};
const teamsForCards = teams.map((team) => ({
...team,
createdAt: new Date(team.createdAt),
updatedAt: new Date(team.updatedAt),
}));
if (isLoading) {
return (
<main className="container mx-auto px-4 py-8">
@@ -80,6 +107,7 @@ function TeamsPageContent(): ReactElement {
<Button
variant="primary"
onClick={() => {
setCreateError(null);
setShowCreateModal(true);
}}
>
@@ -87,7 +115,11 @@ function TeamsPageContent(): ReactElement {
</Button>
</div>
{teams.length === 0 ? (
{loadError !== null ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : teamsForCards.length === 0 ? (
<div className="text-center p-12 bg-gray-50 rounded-lg">
<p className="text-lg text-gray-500 mb-4">No teams yet</p>
<p className="text-sm text-gray-400 mb-6">
@@ -96,6 +128,7 @@ function TeamsPageContent(): ReactElement {
<Button
variant="primary"
onClick={() => {
setCreateError(null);
setShowCreateModal(true);
}}
>
@@ -104,13 +137,12 @@ function TeamsPageContent(): ReactElement {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{teams.map((team) => (
{teamsForCards.map((team) => (
<TeamCard key={team.id} team={team} workspaceId={workspaceId} />
))}
</div>
)}
{/* Create Team Modal */}
{showCreateModal && (
<Modal
isOpen={showCreateModal}
@@ -143,6 +175,11 @@ function TeamsPageContent(): ReactElement {
fullWidth
disabled={isCreating}
/>
{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 gap-2 justify-end pt-4">
<Button
variant="ghost"
@@ -155,7 +192,7 @@ function TeamsPageContent(): ReactElement {
</Button>
<Button
variant="primary"
onClick={handleCreateTeam}
onClick={() => void handleCreateTeam()}
disabled={!newTeamName.trim() || isCreating}
>
{isCreating ? "Creating..." : "Create Team"}
@@ -168,12 +205,7 @@ function TeamsPageContent(): ReactElement {
);
}
/**
* Teams Page Entry Point
* Shows development content or Coming Soon based on environment
*/
export default function TeamsPage(): ReactElement {
// In production, show Coming Soon placeholder
if (!isDevelopment) {
return (
<ComingSoon
@@ -187,6 +219,5 @@ export default function TeamsPage(): ReactElement {
);
}
// In development, show the full page with mock data
return <TeamsPageContent />;
}