feat: SQL filtering, Users admin tab, dashboard polish

- Move ticket filtering from in-memory to SQL WHERE clauses
  (queue_id, status, owner use Drizzle eq/isNull; text search uses ilike;
  custom field filters use EXISTS subqueries)
- Add limit param to GET /tickets
- Add POST/PATCH/DELETE /users routes
- Add Users tab to admin page with create/edit/delete
- Smart widget positioning in dashboard (3-column grid fill)
- Show pattern hint below CF inputs in New Ticket dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 13:04:10 +02:00
parent affbbdaa46
commit c6c5272e50
6 changed files with 297 additions and 72 deletions

View File

@@ -11,6 +11,7 @@ import {
Settings2Icon,
SlidersHorizontalIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -60,8 +61,12 @@ import {
unassignQueueCustomField,
createCustomField,
updateCustomField,
getUsers,
createUser,
updateUser,
deleteUser,
} from "@/lib/api";
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview } from "@/lib/types";
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User } from "@/lib/types";
import { cn } from "@/lib/utils";
function AdminHeader() {
@@ -149,6 +154,10 @@ export default function AdminPage() {
<SlidersHorizontalIcon className="h-4 w-4" />
Custom fields
</TabsTrigger>
<TabsTrigger value="users" className="px-3">
<UsersIcon className="h-4 w-4" />
Users
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
@@ -167,6 +176,9 @@ export default function AdminPage() {
<TabsContent value="customfields" className="m-0">
<CustomFieldsTab />
</TabsContent>
<TabsContent value="users" className="m-0">
<UsersTab />
</TabsContent>
</div>
</Tabs>
</div>
@@ -2011,6 +2023,143 @@ Location: {{custom_fields.location}}`;
);
}
function UsersTab() {
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 [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
const { data, error } = await getUsers();
if (error) setError(error);
else setUsers(data ?? []);
setLoading(false);
}, []);
useEffect(() => {
void Promise.resolve().then(() => fetchUsers());
}, [fetchUsers]);
const resetForm = () => {
setEditingId(null);
setUsername("");
setEmail("");
setSaveError(null);
};
const handleSave = async () => {
if (!username.trim()) return;
setSaving(true);
setSaveError(null);
const payload = { username: username.trim(), email: email.trim() || null };
const { error } = editingId
? await updateUser(editingId, payload)
: await createUser(payload);
setSaving(false);
if (error) { setSaveError(error); return; }
resetForm();
await fetchUsers();
};
const handleDelete = async (id: string) => {
setDeletingId(id);
await deleteUser(id);
if (editingId === id) resetForm();
await fetchUsers();
setDeletingId(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">Users ({users.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Create, update, and manage user accounts for ticket assignment.</p>
</div>
<Button size="sm" onClick={resetForm} className="h-8 bg-primary">
<PlusIcon className="h-4 w-4" />
New user
</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">User directory</div>
</div>
<div className="max-h-[400px] overflow-auto">
{users.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No users yet.</div>
) : (
users.map((user) => (
<div
key={user.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 === user.id && "bg-primary/10"
)}
>
<button
type="button"
onClick={() => { setEditingId(user.id); setUsername(user.username); setEmail(user.email ?? ""); setSaveError(null); }}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-medium text-foreground">{user.username}</div>
<div className="truncate text-xs text-muted-foreground">{user.email ?? "No email"}</div>
</button>
<button
type="button"
onClick={() => void handleDelete(user.id)}
disabled={deletingId === user.id}
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground/60 transition-all hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
title="Delete user"
>
<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 user" : "New user"}
</div>
<h3 className="mt-0.5 text-lg font-semibold text-foreground">{username.trim() || "Untitled"}</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="u-username">Username</Label>
<Input id="u-username" placeholder="gjermund" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="u-email">Email</Label>
<Input id="u-email" type="email" placeholder="gjermund@example.com" value={email} onChange={(e) => setEmail(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={!username.trim() || saving} size="sm" className="bg-primary">
{saving ? "Saving..." : editingId ? "Save changes" : "Create user"}
</Button>
</div>
</div>
</div>
</div>
)}
</section>
);
}
function CustomFieldsTab() {
const [fields, setFields] = useState<CustomField[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);

View File

@@ -97,7 +97,15 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
const handleAddWidget = async () => {
if (!addViewId || !addTitle.trim()) return;
setAdding(true);
const pos = { x: 0, y: widgets.length, w: 4, h: 2 };
// Smart positioning: fill a 3-column grid (4 units each in 12-col grid)
const COLS = 3; const W = 4; const H = 2;
const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`));
let x = 0; let y = 0;
while (occupied.has(`${x},${y}`)) {
x += W;
if (x >= COLS * W) { x = 0; y += H; }
}
const pos = { x, y, w: W, h: H };
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
const { data, error } = await createWidget(id, {
view_id: addViewId,

View File

@@ -1110,10 +1110,15 @@ function TicketWorkbenchContent() {
...current,
[field.id]: event.target.value,
}))}
placeholder={field.pattern ? field.pattern : "Optional value"}
placeholder={field.pattern ? `Pattern: ${field.pattern}` : "Optional value"}
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
/>
)}
{field.pattern && (
<span className="text-[11px] text-muted-foreground">
Must match: <code className="rounded bg-muted px-1 font-mono">{field.pattern}</code>
</span>
)}
</label>
);
})

View File

@@ -95,6 +95,24 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
return request<User[]>("/users");
}
export async function createUser(data: {
username: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
}
export async function updateUser(id: string, data: {
username?: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
}