feat: team assignment on tickets + My team's tickets view

Backend:
- team_id column on tickets table
- team_id filter in GET /tickets (resolves team members)
- team_id in UpdateTicketSchema + PATCH handler
- SetTeam transaction type

Frontend:
- Team selector in ticket detail properties sidebar
- My team's tickets in sidebar (when user belongs to a team)
- team_id passed through to API from ticket list page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 14:40:00 +02:00
parent 4157a7b0af
commit 3d7ba0d6a7
11 changed files with 1412 additions and 4 deletions

View File

@@ -198,12 +198,14 @@ function TicketWorkbenchContent() {
}
}
const routeTeamId = searchParams.get("team_id") ?? "";
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
getTickets({
q: searchQuery.trim() || undefined,
status: apiStatus || undefined,
queue_id: activeQueue || apiQueue || undefined,
owner_id: apiOwner || undefined,
team_id: routeTeamId || undefined,
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
}),
getQueues(),

View File

@@ -26,6 +26,7 @@ import {
getQueues,
getLifecycles,
getUsers,
getTeams,
getQueueCustomFields,
previewTicket,
updateTicket,
@@ -37,6 +38,7 @@ import type {
Transaction,
Queue,
Lifecycle,
Team,
User,
QueueCustomField,
PreviewResult,
@@ -212,6 +214,7 @@ export default function TicketDetailPage({
const [queue, setQueue] = useState<Queue | null>(null);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -230,7 +233,7 @@ export default function TicketDetailPage({
const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null);
const [editingSubject, setEditingSubject] = useState(false);
const [subjectDraft, setSubjectDraft] = useState("");
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null);
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null);
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
@@ -239,12 +242,13 @@ export default function TicketDetailPage({
setLoading(true);
setError(null);
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([
getTicket(id),
getTicketTransactions(id),
getQueues(),
getLifecycles(),
getUsers(),
getTeams(),
]);
if (ticketRes.error) {
@@ -290,6 +294,7 @@ export default function TicketDetailPage({
setError((prev) => prev || usersRes.error);
} else {
setUsers(usersRes.data ?? []);
setTeams(teamsRes.data ?? []);
}
setLoading(false);
@@ -379,6 +384,29 @@ export default function TicketDetailPage({
}
};
const handleTeamChange = async (teamId: string) => {
if (!ticket || fieldSaving) return;
const nextTeamId = teamId || null;
if (nextTeamId === ticket.team_id) return;
setFieldSaving("team");
setFieldError(null);
const { data, error } = await updateTicket(id, { team_id: nextTeamId });
setFieldSaving(null);
if (error) {
setFieldError(error);
return;
}
if (data) {
setTicket(data.ticket);
await refreshTransactions();
}
};
const handleOwnerChange = async (ownerId: string) => {
if (!ticket || fieldSaving) return;
const nextOwnerId = ownerId || null;
@@ -909,6 +937,25 @@ export default function TicketDetailPage({
</select>
</dd>
</div>
<div className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2.5">
<dt className="text-[11px] font-semibold uppercase text-muted-foreground">Team</dt>
<dd className="min-w-0">
<select
value={ticket.team_id ?? ""}
onChange={(event) => void handleTeamChange(event.target.value)}
disabled={fieldSaving === "team"}
className="h-8 w-full rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring disabled:opacity-60"
aria-label="Team"
>
<option value="">No team</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</dd>
</div>
<PropertyRow label="Priority" value="Not set" />
</dl>
</section>

View File

@@ -8,6 +8,7 @@ import {
LayoutGridIcon,
PlusIcon,
UserIcon,
UsersIcon,
InboxIcon,
ClockIcon,
SettingsIcon,
@@ -91,6 +92,7 @@ function SidebarNav() {
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [myTeamId, setMyTeamId] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({
dashboards: true,
queues: true,
@@ -145,6 +147,7 @@ function SidebarNav() {
const userTeams = allTeams.filter((t) =>
(t.members ?? []).some((m) => m.id === myId)
);
setMyTeamId(userTeams[0]?.id ?? null);
const teamIds = new Set(userTeams.map((t) => t.id));
const visible = allDashboards.filter((d) =>
!d.team_id || teamIds.has(d.team_id)
@@ -171,6 +174,13 @@ function SidebarNav() {
count: counts.my,
icon: UserIcon,
},
...(myTeamId ? [{
label: "My team's tickets",
href: `/?view=team&team_id=${myTeamId}`,
param: "team",
count: undefined as number | undefined,
icon: UsersIcon,
}] : []),
{
label: "Unassigned",
href: "/?view=unassigned",

View File

@@ -43,6 +43,7 @@ export async function getTickets(params?: {
status?: string;
q?: string;
owner_id?: string;
team_id?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams();
@@ -50,6 +51,7 @@ export async function getTickets(params?: {
if (params?.status) sp.set("status", params.status);
if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.team_id) sp.set("team_id", params.team_id);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
@@ -72,7 +74,7 @@ export async function createTicket(data: {
return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null; team_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}

View File

@@ -4,6 +4,7 @@ export interface Ticket {
queue_id: string;
status: string;
owner_id: string | null;
team_id: string | null;
creator_id: string;
created_at: string;
updated_at: string;