feat: add teams/groups with dashboard scoping

Schema:
- teams table (name unique, description)
- team_members table (team_id, user_id, unique constraint)
- team_id column on dashboards

API:
- GET/POST/PATCH/DELETE /teams
- POST /teams/:id/members (add user)
- DELETE /teams/:id/members/:userId (remove user)
- dashboards support team_id on create/update

Frontend:
- Teams tab in admin: CRUD + member management with add/remove
- Sidebar: dashboards filtered to user's teams
  (unassigned dashboards visible to all)
- Compact dashboard picker dropdown in sidebar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 13:32:39 +02:00
parent c79cd183d4
commit 3616046b78
11 changed files with 1713 additions and 25 deletions

View File

@@ -65,8 +65,14 @@ import {
createUser,
updateUser,
deleteUser,
getTeams,
createTeam,
updateTeam,
deleteTeam,
addTeamMember,
removeTeamMember,
} from "@/lib/api";
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User } from "@/lib/types";
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
import { cn } from "@/lib/utils";
function AdminHeader() {
@@ -158,6 +164,10 @@ export default function AdminPage() {
<UsersIcon className="h-4 w-4" />
Users
</TabsTrigger>
<TabsTrigger value="teams" className="px-3">
<UsersIcon className="h-4 w-4" />
Teams
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
@@ -179,6 +189,9 @@ export default function AdminPage() {
<TabsContent value="users" className="m-0">
<UsersTab />
</TabsContent>
<TabsContent value="teams" className="m-0">
<TeamsTab />
</TabsContent>
</div>
</Tabs>
</div>
@@ -2160,6 +2173,194 @@ function UsersTab() {
);
}
function TeamsTab() {
const [teams, setTeams] = useState<Team[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [addingMember, setAddingMember] = useState<string | null>(null); // team id being managed
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
const [teamsRes, usersRes] = await Promise.all([getTeams(), getUsers()]);
if (teamsRes.error) setError(teamsRes.error);
else setTeams(teamsRes.data ?? []);
if (usersRes.error) setError((prev) => prev || usersRes.error);
else setUsers(usersRes.data ?? []);
setLoading(false);
}, []);
useEffect(() => { void Promise.resolve().then(() => fetchData()); }, [fetchData]);
const resetForm = () => {
setEditingId(null);
setName("");
setDescription("");
setSaveError(null);
};
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
setSaveError(null);
const payload = { name: name.trim(), description: description.trim() || undefined };
const { error } = editingId
? await updateTeam(editingId, payload)
: await createTeam(payload);
setSaving(false);
if (error) { setSaveError(error); return; }
resetForm();
await fetchData();
};
const handleDelete = async (id: string) => {
setDeletingId(id);
await deleteTeam(id);
if (editingId === id) resetForm();
await fetchData();
setDeletingId(null);
};
const handleAddMember = async (teamId: string, userId: string) => {
await addTeamMember(teamId, userId);
await fetchData();
};
const handleRemoveMember = async (teamId: string, userId: string) => {
await removeTeamMember(teamId, userId);
await fetchData();
};
const selectedTeam = editingId ? teams.find((t) => t.id === editingId) : null;
return (
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Teams ({teams.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Organize users into teams. Assign dashboards to teams.</p>
</div>
<Button size="sm" onClick={resetForm} className="h-8 bg-primary">
<PlusIcon className="h-4 w-4" /> New team
</Button>
</div>
<ErrorBanner error={error} />
{loading ? <LoadingState /> : (
<div className="grid min-h-[400px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 border-b border-border bg-background/70 lg:border-b-0 lg:border-r">
<div className="px-4 py-3">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Teams</div>
</div>
<div className="max-h-[400px] overflow-auto">
{teams.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No teams yet.</div>
) : (
teams.map((team) => (
<div key={team.id}
className={cn(
"flex items-center justify-between border-b border-border/50 px-4 py-2.5 transition-colors hover:bg-accent/40",
editingId === team.id && "bg-primary/10"
)}
>
<button type="button"
onClick={() => { setEditingId(team.id); setName(team.name); setDescription(team.description ?? ""); setSaveError(null); }}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-medium text-foreground">{team.name}</div>
<div className="text-xs text-muted-foreground">{(team.members ?? []).length} members</div>
</button>
<button type="button"
onClick={() => void handleDelete(team.id)}
disabled={deletingId === team.id}
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
</aside>
<div className="min-w-0 p-4">
<div className="mb-4 border-b border-border pb-4">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing team" : "New team"}
</div>
<h3 className="mt-0.5 text-lg font-semibold text-foreground">{name.trim() || "Untitled"}</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="t-name">Name</Label>
<Input id="t-name" placeholder="Support" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="t-desc">Description</Label>
<Input id="t-desc" placeholder="First-line support team" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button onClick={() => void handleSave()} disabled={!name.trim() || saving} size="sm" className="bg-primary">
{saving ? "Saving..." : editingId ? "Save changes" : "Create team"}
</Button>
</div>
{selectedTeam && (
<div className="mt-4 rounded-md border border-border">
<div className="border-b border-border bg-muted/30 px-3 py-2">
<h4 className="text-sm font-semibold text-foreground">Members</h4>
</div>
<div className="p-3 space-y-2">
{(selectedTeam.members ?? []).map((user) => (
<div key={user.id} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium text-foreground">{user.username}</span>
<span className="ml-2 text-xs text-muted-foreground">{user.email ?? "no email"}</span>
</div>
<button type="button"
onClick={() => void handleRemoveMember(selectedTeam.id, user.id)}
className="text-xs text-muted-foreground hover:text-destructive"
>Remove</button>
</div>
))}
{(selectedTeam.members ?? []).length === 0 && (
<p className="text-xs text-muted-foreground">No members yet.</p>
)}
<select
value=""
onChange={(e) => {
if (e.target.value) {
void handleAddMember(selectedTeam.id, e.target.value);
e.target.value = "";
}
}}
className="mt-2 h-8 w-full rounded border border-input bg-card px-2 text-sm outline-none"
>
<option value="">Add member...</option>
{users
.filter((u) => !(selectedTeam.members ?? []).find((m) => m.id === u.id))
.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
</div>
)}
</div>
</div>
</div>
)}
</section>
);
}
function CustomFieldsTab() {
const [fields, setFields] = useState<CustomField[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);