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