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:
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
|
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
|
||||||
import { and, eq, asc } from 'drizzle-orm';
|
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||||
import { ScripEngine } from '../scrip/engine.ts';
|
import { ScripEngine } from '../scrip/engine.ts';
|
||||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||||
@@ -26,94 +26,78 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
const queueId = c.req.query('queue_id');
|
const queueId = c.req.query('queue_id');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const ownerId = c.req.query('owner_id');
|
const ownerId = c.req.query('owner_id');
|
||||||
const query = c.req.query('q')?.trim().toLowerCase() ?? '';
|
const query = c.req.query('q')?.trim() ?? '';
|
||||||
|
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
||||||
const cfFilters = [...params.entries()]
|
const cfFilters = [...params.entries()]
|
||||||
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
||||||
.map(([key, value]) => ({
|
.map(([key, value]) => ({
|
||||||
key: key.slice(3),
|
key: key.slice(3),
|
||||||
value: value.trim().toLowerCase(),
|
value: value.trim(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let result = await db.query.tickets.findMany({
|
// Build SQL WHERE conditions
|
||||||
orderBy: asc(tickets.created_at),
|
const conditions: ReturnType<typeof eq>[] = [];
|
||||||
});
|
|
||||||
|
|
||||||
if (queueId) {
|
if (queueId) {
|
||||||
result = result.filter((ticket) => ticket.queue_id === queueId);
|
conditions.push(eq(tickets.queue_id, queueId));
|
||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
result = result.filter((ticket) => ticket.status === status);
|
conditions.push(eq(tickets.status, status));
|
||||||
}
|
}
|
||||||
if (ownerId) {
|
if (ownerId) {
|
||||||
result = ownerId === 'unassigned'
|
conditions.push(
|
||||||
? result.filter((ticket) => !ticket.owner_id)
|
ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId)
|
||||||
: result.filter((ticket) => ticket.owner_id === ownerId);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsCustomFields = query || cfFilters.length > 0;
|
// Text search: push to SQL via ilike on ticket columns + queue name join
|
||||||
const valuesByTicket = new Map<number, { fieldId: string; fieldKey: string; fieldName: string; value: string }[]>();
|
if (query) {
|
||||||
|
const pattern = `%${query}%`;
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(tickets.subject, pattern),
|
||||||
|
ilike(tickets.status, pattern),
|
||||||
|
sql`${tickets.id}::text ILIKE ${pattern}`
|
||||||
|
)!
|
||||||
|
);
|
||||||
|
// Queue name search requires join — keep as post-filter
|
||||||
|
}
|
||||||
|
|
||||||
if (needsCustomFields && result.length > 0) {
|
// Custom field filters: use EXISTS subquery
|
||||||
const ticketIds = result.map((ticket) => ticket.id);
|
for (const cf of cfFilters) {
|
||||||
const cfValues = await db.query.customFieldValues.findMany({
|
conditions.push(
|
||||||
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
|
exists(
|
||||||
|
db.select({ n: sql`1` })
|
||||||
|
.from(customFieldValues)
|
||||||
|
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(customFieldValues.ticket_id, tickets.id),
|
||||||
|
eq(customFields.key, cf.key),
|
||||||
|
eq(customFieldValues.value, cf.value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.tickets.findMany({
|
||||||
|
where: conditions.length > 0 ? and(...conditions) : undefined,
|
||||||
|
orderBy: asc(tickets.created_at),
|
||||||
|
limit,
|
||||||
});
|
});
|
||||||
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
|
|
||||||
const fields = fieldIds.length > 0
|
|
||||||
? await db.query.customFields.findMany({
|
|
||||||
where: (table, { inArray }) => inArray(table.id, fieldIds),
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
|
||||||
|
|
||||||
for (const value of cfValues) {
|
|
||||||
const rows = valuesByTicket.get(value.ticket_id) ?? [];
|
|
||||||
rows.push({
|
|
||||||
fieldId: value.custom_field_id,
|
|
||||||
fieldKey: fieldMap.get(value.custom_field_id)?.key ?? value.custom_field_id,
|
|
||||||
fieldName: fieldMap.get(value.custom_field_id)?.name ?? value.custom_field_id,
|
|
||||||
value: value.value,
|
|
||||||
});
|
|
||||||
valuesByTicket.set(value.ticket_id, rows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Post-filter for queue name text search (requires in-memory join)
|
||||||
|
let filtered = result;
|
||||||
if (query) {
|
if (query) {
|
||||||
const queuesForSearch = await db.query.queues.findMany();
|
const queuesForSearch = await db.query.queues.findMany();
|
||||||
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
|
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
|
||||||
result = result.filter((ticket) => {
|
filtered = result.filter((ticket) =>
|
||||||
const customFields = valuesByTicket.get(ticket.id) ?? [];
|
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
|
||||||
return (
|
|
||||||
ticket.subject.toLowerCase().includes(query) ||
|
|
||||||
String(ticket.id).includes(query) ||
|
|
||||||
ticket.status.toLowerCase().includes(query) ||
|
|
||||||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query) ||
|
|
||||||
customFields.some((field) =>
|
|
||||||
field.fieldName.toLowerCase().includes(query) ||
|
|
||||||
field.fieldKey.toLowerCase().includes(query) ||
|
|
||||||
field.value.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cfFilters.length > 0) {
|
return c.json(filtered);
|
||||||
result = result.filter((ticket) => {
|
|
||||||
const customFields = valuesByTicket.get(ticket.id) ?? [];
|
|
||||||
return cfFilters.every((filter) =>
|
|
||||||
customFields.some((field) =>
|
|
||||||
(
|
|
||||||
field.fieldId === filter.key ||
|
|
||||||
field.fieldKey.toLowerCase() === filter.key.toLowerCase() ||
|
|
||||||
field.fieldName.toLowerCase() === filter.key.toLowerCase()
|
|
||||||
) &&
|
|
||||||
field.value.toLowerCase() === filter.value
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST / — create ticket
|
// POST / — create ticket
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { asc } from 'drizzle-orm';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { users } from '../db/schema.ts';
|
import { users } from '../db/schema.ts';
|
||||||
|
|
||||||
@@ -13,5 +14,65 @@ export function createUsersRouter(db: Db): Hono {
|
|||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const username = String(body.username ?? '').trim();
|
||||||
|
const email = body.email ? String(body.email).trim() : null;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
throw new HTTPException(400, { message: 'username is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db.insert(users).values({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create user' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(user, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const existing = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<typeof users.$inferInsert> = {};
|
||||||
|
if (body.username !== undefined) updateData.username = String(body.username).trim();
|
||||||
|
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
|
||||||
|
|
||||||
|
const [updated] = await db.update(users)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const existing = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -60,8 +61,12 @@ import {
|
|||||||
unassignQueueCustomField,
|
unassignQueueCustomField,
|
||||||
createCustomField,
|
createCustomField,
|
||||||
updateCustomField,
|
updateCustomField,
|
||||||
|
getUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
} from "@/lib/api";
|
} 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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function AdminHeader() {
|
function AdminHeader() {
|
||||||
@@ -149,6 +154,10 @@ export default function AdminPage() {
|
|||||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||||
Custom fields
|
Custom fields
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users" className="px-3">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
Users
|
||||||
|
</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">
|
||||||
@@ -167,6 +176,9 @@ export default function AdminPage() {
|
|||||||
<TabsContent value="customfields" className="m-0">
|
<TabsContent value="customfields" className="m-0">
|
||||||
<CustomFieldsTab />
|
<CustomFieldsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="users" className="m-0">
|
||||||
|
<UsersTab />
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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() {
|
function CustomFieldsTab() {
|
||||||
const [fields, setFields] = useState<CustomField[]>([]);
|
const [fields, setFields] = useState<CustomField[]>([]);
|
||||||
const [queues, setQueues] = useState<Queue[]>([]);
|
const [queues, setQueues] = useState<Queue[]>([]);
|
||||||
|
|||||||
@@ -97,7 +97,15 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
|||||||
const handleAddWidget = async () => {
|
const handleAddWidget = async () => {
|
||||||
if (!addViewId || !addTitle.trim()) return;
|
if (!addViewId || !addTitle.trim()) return;
|
||||||
setAdding(true);
|
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 config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
|
||||||
const { data, error } = await createWidget(id, {
|
const { data, error } = await createWidget(id, {
|
||||||
view_id: addViewId,
|
view_id: addViewId,
|
||||||
|
|||||||
@@ -1110,10 +1110,15 @@ function TicketWorkbenchContent() {
|
|||||||
...current,
|
...current,
|
||||||
[field.id]: event.target.value,
|
[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"
|
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>
|
</label>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
|
|||||||
return request<User[]>("/users");
|
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 }> {
|
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) });
|
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user