feat: queues have default team, tickets inherit it

- team_id on queues table (optional, can be overridden per-ticket)
- Ticket creation auto-sets team_id from the queue's default
- Queue admin form has team selector (scrip flow node 03)
- Queue API (POST/PATCH) accepts team_id

No enforcement — just a helpful default. Teams and queues
are loosely coupled, not hierarchically locked.

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

View File

@@ -201,23 +201,27 @@ export default function AdminPage() {
function QueuesTab() {
const [queues, setQueues] = useState<Queue[]>([]);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
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 [lifecycleId, setLifecycleId] = useState("");
const [teamId, setTeamId] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const fetchQueues = useCallback(async () => {
setLoading(true);
setError(null);
const [queueRes, lifecycleRes] = await Promise.all([getQueues(), getLifecycles()]);
const [queueRes, lifecycleRes, teamsRes] = await Promise.all([getQueues(), getLifecycles(), getTeams()]);
if (queueRes.error) setError(queueRes.error);
else setQueues(queueRes.data ?? []);
if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error);
else setLifecycles(lifecycleRes.data ?? []);
if (teamsRes.error) setError((prev) => prev || teamsRes.error);
else setTeams(teamsRes.data ?? []);
setLoading(false);
}, []);
@@ -230,6 +234,7 @@ function QueuesTab() {
setName("");
setDescription("");
setLifecycleId("");
setTeamId("");
setSaveError(null);
};
@@ -238,6 +243,7 @@ function QueuesTab() {
setName(queue.name);
setDescription(queue.description ?? "");
setLifecycleId(queue.lifecycle_id ?? "");
setTeamId(queue.team_id ?? "");
setSaveError(null);
};
@@ -249,6 +255,7 @@ function QueuesTab() {
name: name.trim(),
description: description.trim() || null,
lifecycle_id: lifecycleId || null,
team_id: teamId || null,
};
const { data, error } = editingId
? await updateQueue(editingId, payload)
@@ -356,6 +363,19 @@ function QueuesTab() {
</Select>
</div>
</ScripFlowNode>
<ScripFlowNode label="03" title="Default team" description="New tickets in this queue inherit this team. Can be changed per-ticket.">
<div className="grid gap-1.5">
<Select value={teamId || "_none"} onValueChange={(value) => setTeamId(value === "_none" || !value ? "" : value)}>
<SelectTrigger id="q-team"><SelectValue placeholder="No default team" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none">No default team</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>{team.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ScripFlowNode>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
</div>
</div>