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:
@@ -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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user