feat(web): add workspace management UI (M2 #12)

- Create workspace listing page at /settings/workspaces
  - List all user workspaces with role badges
  - Create new workspace functionality
  - Display member count per workspace

- Create workspace detail page at /settings/workspaces/[id]
  - Workspace settings (name, ID, created date)
  - Member management with role editing
  - Invite member functionality
  - Delete workspace (owner only)

- Add workspace components:
  - WorkspaceCard: Display workspace info with role badge
  - WorkspaceSettings: Edit workspace settings and delete
  - MemberList: Display and manage workspace members
  - InviteMember: Send invitations with role selection

- Add WorkspaceMemberWithUser type to shared package
- Follow existing app patterns for styling and structure
- Use mock data (ready for API integration)
This commit is contained in:
Jason Woltje
2026-01-29 16:59:26 -06:00
parent 287a0e2556
commit 5291fece26
43 changed files with 4152 additions and 99 deletions

View File

@@ -0,0 +1,32 @@
import type { Team } from "@mosaic/shared";
import { Card, CardHeader, CardContent } from "@mosaic/ui";
import Link from "next/link";
interface TeamCardProps {
team: Team;
workspaceId: string;
}
export function TeamCard({ team, workspaceId }: TeamCardProps) {
return (
<Link href={`/settings/workspaces/${workspaceId}/teams/${team.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<CardHeader>
<h3 className="text-lg font-semibold text-gray-900">{team.name}</h3>
</CardHeader>
<CardContent>
{team.description ? (
<p className="text-sm text-gray-600">{team.description}</p>
) : (
<p className="text-sm text-gray-400 italic">No description</p>
)}
<div className="mt-3 flex items-center gap-2 text-xs text-gray-500">
<span>
Created {new Date(team.createdAt).toLocaleDateString()}
</span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import type { Team } from "@mosaic/shared";
import { Card, CardHeader, CardContent, CardFooter, Button, Input, Textarea } from "@mosaic/ui";
interface TeamSettingsProps {
team: Team;
workspaceId: string;
onUpdate: (data: { name?: string; description?: string }) => Promise<void>;
onDelete: () => Promise<void>;
}
export function TeamSettings({ team, workspaceId, onUpdate, onDelete }: TeamSettingsProps) {
const [name, setName] = useState(team.name);
const [description, setDescription] = useState(team.description || "");
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const hasChanges = name !== team.name || description !== (team.description || "");
const handleSave = async () => {
if (!hasChanges) return;
setIsSaving(true);
try {
await onUpdate({
name: name !== team.name ? name : undefined,
description: description !== (team.description || "") ? description : undefined,
});
setIsEditing(false);
} catch (error) {
console.error("Failed to update team:", error);
alert("Failed to update team. Please try again.");
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setName(team.name);
setDescription(team.description || "");
setIsEditing(false);
};
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete();
} catch (error) {
console.error("Failed to delete team:", error);
alert("Failed to delete team. Please try again.");
setIsDeleting(false);
}
};
return (
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">Team Settings</h2>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Input
label="Team Name"
value={name}
onChange={(e) => {
setName(e.target.value);
setIsEditing(true);
}}
placeholder="Enter team name"
fullWidth
disabled={isSaving}
/>
<Textarea
label="Description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
setIsEditing(true);
}}
placeholder="Enter team description (optional)"
fullWidth
disabled={isSaving}
/>
</div>
</CardContent>
<CardFooter>
<div className="flex items-center justify-between w-full">
<div className="flex gap-2">
{isEditing && (
<>
<Button
variant="primary"
onClick={handleSave}
disabled={!hasChanges || isSaving || !name.trim()}
>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
<Button variant="ghost" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
</>
)}
</div>
<div>
{!showDeleteConfirm ? (
<Button
variant="danger"
onClick={() => setShowDeleteConfirm(true)}
disabled={isSaving}
>
Delete Team
</Button>
) : (
<div className="flex gap-2">
<span className="text-sm text-gray-600 self-center">
Are you sure?
</span>
<Button variant="danger" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Confirm Delete"}
</Button>
<Button
variant="ghost"
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
>
Cancel
</Button>
</div>
)}
</div>
</div>
</CardFooter>
</Card>
);
}