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:
19
drizzle/migrations/0005_spotty_leader.sql
Normal file
19
drizzle/migrations/0005_spotty_leader.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "team_members" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"team_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "team_members_team_id_user_id_unique" UNIQUE("team_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "teams" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT "teams_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboards" ADD COLUMN "team_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;
|
||||||
1291
drizzle/migrations/meta/0005_snapshot.json
Normal file
1291
drizzle/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1780996807814,
|
"when": 1780996807814,
|
||||||
"tag": "0004_sturdy_natasha_romanoff",
|
"tag": "0004_sturdy_natasha_romanoff",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781004398567,
|
||||||
|
"tag": "0005_spotty_leader",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { relations, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -124,10 +124,35 @@ export const views = pgTable('views', {
|
|||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const teams = pgTable('teams', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
description: text('description'),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamsRelations = relations(teams, ({ many }) => ({
|
||||||
|
members: many(teamMembers),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const teamMembers = pgTable('team_members', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
|
||||||
|
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueMember: unique('team_members_team_id_user_id_unique').on(table.team_id, table.user_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
|
||||||
|
team: one(teams, { fields: [teamMembers.team_id], references: [teams.id] }),
|
||||||
|
user: one(users, { fields: [teamMembers.user_id], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const dashboards = pgTable('dashboards', {
|
export const dashboards = pgTable('dashboards', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
|
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
|
||||||
layout: jsonb('layout').default('[]'),
|
layout: jsonb('layout').default('[]'),
|
||||||
is_default: boolean('is_default').default(false),
|
is_default: boolean('is_default').default(false),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createUsersRouter } from './routes/users.ts';
|
|||||||
import { createTemplatesRouter } from './routes/templates.ts';
|
import { createTemplatesRouter } from './routes/templates.ts';
|
||||||
import { createViewsRouter } from './routes/views.ts';
|
import { createViewsRouter } from './routes/views.ts';
|
||||||
import { createDashboardsRouter } from './routes/dashboards.ts';
|
import { createDashboardsRouter } from './routes/dashboards.ts';
|
||||||
|
import { createTeamsRouter } from './routes/teams.ts';
|
||||||
|
|
||||||
let db: Db | null = null;
|
let db: Db | null = null;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ app.route('/users', createUsersRouter(getDb()));
|
|||||||
app.route('/templates', createTemplatesRouter(getDb()));
|
app.route('/templates', createTemplatesRouter(getDb()));
|
||||||
app.route('/views', createViewsRouter(getDb()));
|
app.route('/views', createViewsRouter(getDb()));
|
||||||
app.route('/dashboards', createDashboardsRouter(getDb()));
|
app.route('/dashboards', createDashboardsRouter(getDb()));
|
||||||
|
app.route('/teams', createTeamsRouter(getDb()));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
export { app };
|
export { app };
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function createDashboardsRouter(db: Db): Hono {
|
|||||||
const [dashboard] = await db.insert(dashboards).values({
|
const [dashboard] = await db.insert(dashboards).values({
|
||||||
name,
|
name,
|
||||||
description: body.description ?? null,
|
description: body.description ?? null,
|
||||||
|
team_id: body.team_id || null,
|
||||||
layout: body.layout ?? [],
|
layout: body.layout ?? [],
|
||||||
is_default: body.is_default ?? false,
|
is_default: body.is_default ?? false,
|
||||||
}).returning();
|
}).returning();
|
||||||
@@ -87,9 +88,9 @@ export function createDashboardsRouter(db: Db): Hono {
|
|||||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||||
if (body.description !== undefined) updateData.description = body.description ?? null;
|
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||||
if (body.layout !== undefined) updateData.layout = body.layout;
|
if (body.layout !== undefined) updateData.layout = body.layout;
|
||||||
|
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||||
if (body.is_default !== undefined) {
|
if (body.is_default !== undefined) {
|
||||||
updateData.is_default = body.is_default;
|
updateData.is_default = body.is_default;
|
||||||
// If setting this as default, unset others
|
|
||||||
if (body.is_default) {
|
if (body.is_default) {
|
||||||
await db.update(dashboards)
|
await db.update(dashboards)
|
||||||
.set({ is_default: false })
|
.set({ is_default: false })
|
||||||
|
|||||||
98
src/routes/teams.ts
Normal file
98
src/routes/teams.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { teams, teamMembers, users, dashboards } from '../db/schema.ts';
|
||||||
|
|
||||||
|
export function createTeamsRouter(db: Db): Hono {
|
||||||
|
const router = new Hono();
|
||||||
|
|
||||||
|
// GET /teams — list all with member details
|
||||||
|
router.get('/', async (c) => {
|
||||||
|
const allTeams = await db.query.teams.findMany({
|
||||||
|
orderBy: asc(teams.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.all(allTeams.map(async (team) => {
|
||||||
|
const members = await db.query.teamMembers.findMany({
|
||||||
|
where: eq(teamMembers.team_id, team.id),
|
||||||
|
});
|
||||||
|
const memberUsers = members.length > 0
|
||||||
|
? await db.query.users.findMany({
|
||||||
|
where: (table, { inArray }) => inArray(table.id, members.map((m) => m.user_id)),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
return { ...team, members: memberUsers };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /teams
|
||||||
|
router.post('/', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const name = String(body.name ?? '').trim();
|
||||||
|
if (!name) throw new HTTPException(400, { message: 'name is required' });
|
||||||
|
|
||||||
|
const [team] = await db.insert(teams).values({
|
||||||
|
name,
|
||||||
|
description: body.description ?? null,
|
||||||
|
}).returning();
|
||||||
|
if (!team) throw new HTTPException(500, { message: 'Failed to create team' });
|
||||||
|
return c.json(team, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /teams/:id
|
||||||
|
router.patch('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
|
||||||
|
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
|
||||||
|
|
||||||
|
const updateData: Partial<typeof teams.$inferInsert> = {};
|
||||||
|
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||||
|
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||||
|
|
||||||
|
const [updated] = await db.update(teams).set(updateData).where(eq(teams.id, id)).returning();
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /teams/:id
|
||||||
|
router.delete('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
|
||||||
|
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
|
||||||
|
await db.delete(teams).where(eq(teams.id, id));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /teams/:id/members — add member
|
||||||
|
router.post('/:id/members', async (c) => {
|
||||||
|
const teamId = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const userId = String(body.user_id ?? '').trim();
|
||||||
|
if (!userId) throw new HTTPException(400, { message: 'user_id is required' });
|
||||||
|
|
||||||
|
const team = await db.query.teams.findFirst({ where: eq(teams.id, teamId) });
|
||||||
|
if (!team) throw new HTTPException(404, { message: 'Team not found' });
|
||||||
|
|
||||||
|
const [member] = await db.insert(teamMembers).values({
|
||||||
|
team_id: teamId,
|
||||||
|
user_id: userId,
|
||||||
|
}).returning();
|
||||||
|
if (!member) throw new HTTPException(500, { message: 'Failed to add member' });
|
||||||
|
return c.json(member, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /teams/:id/members/:userId
|
||||||
|
router.delete('/:id/members/:userId', async (c) => {
|
||||||
|
const teamId = c.req.param('id');
|
||||||
|
const userId = c.req.param('userId');
|
||||||
|
await db.delete(teamMembers).where(
|
||||||
|
and(eq(teamMembers.team_id, teamId), eq(teamMembers.user_id, userId))
|
||||||
|
);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -65,8 +65,14 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
getTeams,
|
||||||
|
createTeam,
|
||||||
|
updateTeam,
|
||||||
|
deleteTeam,
|
||||||
|
addTeamMember,
|
||||||
|
removeTeamMember,
|
||||||
} from "@/lib/api";
|
} 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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function AdminHeader() {
|
function AdminHeader() {
|
||||||
@@ -158,6 +164,10 @@ export default function AdminPage() {
|
|||||||
<UsersIcon className="h-4 w-4" />
|
<UsersIcon className="h-4 w-4" />
|
||||||
Users
|
Users
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="teams" className="px-3">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
Teams
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
|
<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">
|
<TabsContent value="users" className="m-0">
|
||||||
<UsersTab />
|
<UsersTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="teams" className="m-0">
|
||||||
|
<TeamsTab />
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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() {
|
function CustomFieldsTab() {
|
||||||
const [fields, setFields] = useState<CustomField[]>([]);
|
const [fields, setFields] = useState<CustomField[]>([]);
|
||||||
const [queues, setQueues] = useState<Queue[]>([]);
|
const [queues, setQueues] = useState<Queue[]>([]);
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
CommandIcon,
|
CommandIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, createDashboard } from "@/lib/api";
|
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
|
||||||
import type { Dashboard, Queue, SavedView, User } from "@/lib/types";
|
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -100,48 +100,58 @@ function SidebarNav() {
|
|||||||
const [addingDashboard, setAddingDashboard] = useState(false);
|
const [addingDashboard, setAddingDashboard] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Find current user and compute view counts
|
async function load() {
|
||||||
Promise.all([getTickets(), getUsers()]).then(([ticketRes, userRes]) => {
|
// Find current user
|
||||||
|
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
||||||
const data = ticketRes.data;
|
const data = ticketRes.data;
|
||||||
const users = userRes.data ?? [];
|
const users = userRes.data ?? [];
|
||||||
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
|
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
|
||||||
if (currentUser) setCurrentUserId(currentUser.id);
|
const myId = currentUser?.id ?? null;
|
||||||
|
setCurrentUserId(myId);
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const myId = currentUser?.id;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const week = 7 * 24 * 60 * 60 * 1000;
|
const week = 7 * 24 * 60 * 60 * 1000;
|
||||||
setCounts({
|
setCounts({
|
||||||
all: data.length,
|
all: data.length,
|
||||||
my: myId ? data.filter((t) => t.owner_id === myId).length : 0,
|
my: myId ? data.filter((t) => t.owner_id === myId).length : 0,
|
||||||
unassigned: data.filter((t) => !t.owner_id).length,
|
unassigned: data.filter((t) => !t.owner_id).length,
|
||||||
recent: data.filter(
|
recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
|
||||||
(t) => new Date(t.updated_at).getTime() > now - week
|
|
||||||
).length,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
getQueues().then(({ data }) => {
|
// Queues
|
||||||
if (data) {
|
const queueRes = await getQueues();
|
||||||
Promise.all(
|
if (queueRes.data) {
|
||||||
data.map((q) =>
|
const qs = await Promise.all(
|
||||||
|
queueRes.data.map((q) =>
|
||||||
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
|
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
|
||||||
...q,
|
...q,
|
||||||
count: tickets?.length ?? 0,
|
count: tickets?.length ?? 0,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
).then(setQueues);
|
);
|
||||||
|
setQueues(qs);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
getViews().then(({ data }) => {
|
// Views
|
||||||
if (data) setSavedViews(data);
|
const viewRes = await getViews();
|
||||||
});
|
if (viewRes.data) setSavedViews(viewRes.data);
|
||||||
|
|
||||||
getDashboards().then(({ data }) => {
|
// Dashboards scoped to user's teams
|
||||||
if (data) setDashboards(data);
|
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
||||||
});
|
const allDashboards = dashRes.data ?? [];
|
||||||
|
const allTeams = teamRes.data ?? [];
|
||||||
|
const userTeams = allTeams.filter((t) =>
|
||||||
|
(t.members ?? []).some((m) => m.id === myId)
|
||||||
|
);
|
||||||
|
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||||
|
const visible = allDashboards.filter((d) =>
|
||||||
|
!d.team_id || teamIds.has(d.team_id)
|
||||||
|
);
|
||||||
|
setDashboards(visible);
|
||||||
|
}
|
||||||
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const collapsed = useSidebarCollapsed();
|
const collapsed = useSidebarCollapsed();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
Dashboard,
|
Dashboard,
|
||||||
DashboardWidget,
|
DashboardWidget,
|
||||||
WidgetData,
|
WidgetData,
|
||||||
|
Team,
|
||||||
User,
|
User,
|
||||||
Transaction,
|
Transaction,
|
||||||
SavedView,
|
SavedView,
|
||||||
@@ -335,3 +336,27 @@ export async function deleteWidget(dashboardId: string, widgetId: string): Promi
|
|||||||
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
|
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
|
||||||
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
|
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTeams(): Promise<{ data: Team[] | null; error: string | null }> {
|
||||||
|
return request<Team[]>("/teams");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeam(data: { name: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
|
||||||
|
return request<Team>("/teams", { method: "POST", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTeam(id: string, data: { name?: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
|
||||||
|
return request<Team>(`/teams/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeam(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/teams/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTeamMember(teamId: string, userId: string): Promise<{ data: unknown | null; error: string | null }> {
|
||||||
|
return request<unknown>(`/teams/${teamId}/members`, { method: "POST", body: JSON.stringify({ user_id: userId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,10 +146,19 @@ export interface SavedView {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
members?: User[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Dashboard {
|
export interface Dashboard {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
team_id: string | null;
|
||||||
layout: unknown[];
|
layout: unknown[];
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user