feat: watcher/CC system, SLA engine, and rich text comments
- Watcher system: ticket_watchers table, watch/unwatch endpoints, notifications to watchers on comments and updates, watcher/cc recipient sources in SendEmail scrip action, watch toggle and watcher avatars in ticket detail UI - SLA engine: sla_policies table, SLA deadline columns on tickets, CRUD routes, OnSlaBreach scrip condition, scheduler SLA calculation, deadlines set on create/reply, cleared on resolve, SLA indicators on ticket list and detail, SLA Policies tab in admin - Rich text: marked-based markdown rendering with XSS safety, Write/Preview toggle in comment composer, styled prose output
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "16.2.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ActivityIcon,
|
||||
Clock3Icon,
|
||||
DatabaseIcon,
|
||||
FileTextIcon,
|
||||
GitBranchIcon,
|
||||
@@ -71,8 +72,12 @@ import {
|
||||
deleteTeam,
|
||||
addTeamMember,
|
||||
removeTeamMember,
|
||||
getSlaPolicies,
|
||||
createSlaPolicy,
|
||||
updateSlaPolicy,
|
||||
deleteSlaPolicy,
|
||||
} from "@/lib/api";
|
||||
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
|
||||
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team, SlaPolicy } from "@/lib/types";
|
||||
import { ScripWizard } from "@/components/scrip-wizard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -162,6 +167,10 @@ export default function AdminPage() {
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
Teams
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sla" className="px-3">
|
||||
<Clock3Icon className="h-4 w-4" />
|
||||
SLA Policies
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
|
||||
@@ -186,6 +195,9 @@ export default function AdminPage() {
|
||||
<TabsContent value="teams" className="m-0">
|
||||
<TeamsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sla" className="m-0">
|
||||
<SlaPoliciesTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -2399,6 +2411,209 @@ function TeamsTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function SlaPoliciesTab() {
|
||||
const [policies, setPolicies] = useState<SlaPolicy[]>([]);
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [queueId, setQueueId] = useState("");
|
||||
const [responseMinutes, setResponseMinutes] = useState("");
|
||||
const [resolutionMinutes, setResolutionMinutes] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [policiesRes, queuesRes] = await Promise.all([
|
||||
getSlaPolicies(),
|
||||
getQueues(),
|
||||
]);
|
||||
if (policiesRes.error) setError(policiesRes.error);
|
||||
else setPolicies(policiesRes.data ?? []);
|
||||
if (queuesRes.data) setQueues(queuesRes.data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void fetchData(); }, [fetchData]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setName("");
|
||||
setQueueId("");
|
||||
setResponseMinutes("");
|
||||
setResolutionMinutes("");
|
||||
setDescription("");
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
const handleEdit = (p: SlaPolicy) => {
|
||||
setEditingId(p.id);
|
||||
setName(p.name);
|
||||
setQueueId(p.queue_id ?? "");
|
||||
setResponseMinutes(p.response_time_minutes?.toString() ?? "");
|
||||
setResolutionMinutes(p.resolution_time_minutes?.toString() ?? "");
|
||||
setDescription(p.description ?? "");
|
||||
setDisabled(p.disabled);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
queue_id: queueId || undefined,
|
||||
response_time_minutes: responseMinutes ? parseInt(responseMinutes, 10) : undefined,
|
||||
resolution_time_minutes: resolutionMinutes ? parseInt(resolutionMinutes, 10) : undefined,
|
||||
description: description || undefined,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
const { error: updateErr } = await updateSlaPolicy(editingId, data);
|
||||
if (updateErr) setError(updateErr);
|
||||
else resetForm();
|
||||
} else {
|
||||
const { error: createErr } = await createSlaPolicy(data);
|
||||
if (createErr) setError(createErr);
|
||||
else resetForm();
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this SLA policy?")) return;
|
||||
const { error: deleteErr } = await deleteSlaPolicy(id);
|
||||
if (deleteErr) setError(deleteErr);
|
||||
else await fetchData();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-10 animate-pulse rounded bg-muted" />)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 text-xs underline" type="button">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit form */}
|
||||
<div className="rounded-lg border border-border/50 bg-card p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
||||
{editingId ? "Edit SLA Policy" : "New SLA Policy"}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-[10px] font-medium text-muted-foreground">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Standard SLA" className="mt-1 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] font-medium text-muted-foreground">Queue</Label>
|
||||
<Select value={queueId || "__global__"} onValueChange={(val) => setQueueId(val === "__global__" ? "" : val ?? "")}>
|
||||
<SelectTrigger className="mt-1 h-8"><SelectValue placeholder="All queues (global)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__global__">All queues (global)</SelectItem>
|
||||
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] font-medium text-muted-foreground">Response (minutes)</Label>
|
||||
<Input value={responseMinutes} onChange={(e) => setResponseMinutes(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 60" className="mt-1 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] font-medium text-muted-foreground">Resolution (minutes)</Label>
|
||||
<Input value={resolutionMinutes} onChange={(e) => setResolutionMinutes(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 480" className="mt-1 h-8" />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-[10px] font-medium text-muted-foreground">Description</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className="mt-1 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input type="checkbox" checked={disabled} onChange={(e) => setDisabled(e.target.checked)} className="h-3.5 w-3.5 rounded" />
|
||||
Disabled
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
{editingId && (
|
||||
<Button variant="ghost" size="sm" onClick={resetForm} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => void handleSave()} disabled={!name.trim() || saving} type="button">
|
||||
{saving ? "Saving..." : editingId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy list */}
|
||||
<div className="rounded-lg border border-border/50">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Queue</TableHead>
|
||||
<TableHead>Response</TableHead>
|
||||
<TableHead>Resolution</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{policies.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No SLA policies defined
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : policies.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium text-foreground">{p.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.queue_id ? (queues.find((q) => q.id === p.queue_id)?.name ?? p.queue_id) : "All queues"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.response_time_minutes ? `${p.response_time_minutes}m` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.resolution_time_minutes ? `${p.resolution_time_minutes}m` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.disabled ? (
|
||||
<Badge variant="secondary" className="text-[10px]">Disabled</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">Active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(p)} type="button">Edit</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleDelete(p.id)} className="text-destructive hover:text-destructive" type="button">
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomFieldsTab() {
|
||||
const [fields, setFields] = useState<CustomField[]>([]);
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
|
||||
@@ -1094,6 +1094,23 @@ function TicketWorkbenchContent() {
|
||||
style={{ backgroundColor: STATUS_META[ticket.status]?.color ?? "#71717a" }}
|
||||
title={statusLabel(ticket.status)}
|
||||
/>
|
||||
{(ticket as any).sla_breached && (
|
||||
<span
|
||||
className="h-2 w-2 rounded-full shrink-0 bg-red-500"
|
||||
title={`SLA breached: ${(ticket as any).sla_breached}`}
|
||||
/>
|
||||
)}
|
||||
{!(ticket as any).sla_breached && (ticket as any).sla_resolution_deadline && (
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
new Date((ticket as any).sla_resolution_deadline) < new Date(Date.now() + 60 * 60 * 1000)
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
)}
|
||||
title={`SLA due ${formatDistanceToNow(new Date((ticket as any).sla_resolution_deadline), { addSuffix: true })}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{row1Fields.map((col) => {
|
||||
const cellStyle = {
|
||||
|
||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BellIcon,
|
||||
BellOffIcon,
|
||||
BotIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
@@ -40,6 +42,9 @@ import {
|
||||
createTicketLink,
|
||||
deleteTicketLink,
|
||||
mergeTickets,
|
||||
getWatchers,
|
||||
addWatcher,
|
||||
removeWatcher,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
Ticket,
|
||||
@@ -54,10 +59,12 @@ import type {
|
||||
AttachmentUploadResult,
|
||||
Attachment,
|
||||
TicketLink,
|
||||
Watcher,
|
||||
} from "@/lib/types";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SearchableSelect } from "@/components/searchable-select";
|
||||
import { cn, formatTicketId } from "@/lib/utils";
|
||||
import { renderMarkdown } from "@/lib/markdown";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
new: "#64748b",
|
||||
@@ -107,6 +114,15 @@ function StatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentUserId(): string {
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||
if (!token) return "";
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.sub || "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
function userLabel(users: User[], userId: string | null) {
|
||||
if (!userId) return "Unassigned";
|
||||
const user = users.find((item) => item.id === userId);
|
||||
@@ -241,9 +257,10 @@ function TransactionCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||
{body}
|
||||
</p>
|
||||
<div
|
||||
className="mt-1.5 prose prose-sm max-w-none text-foreground/90 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_code]:rounded [&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_code]:font-mono [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border/50 [&_pre]:bg-muted/40 [&_pre]:p-3 [&_pre]:text-xs [&_pre]:overflow-x-auto [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:underline [&_p]:leading-relaxed [&_hr]:border-border/50"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(body || '') }}
|
||||
/>
|
||||
{tx.attachments && tx.attachments.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{tx.attachments.map((att) => (
|
||||
@@ -307,6 +324,7 @@ export default function TicketDetailPage({
|
||||
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
||||
const [composerMode, setComposerMode] = useState<"write" | "preview">("write");
|
||||
const [timeMinutes, setTimeMinutes] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
@@ -337,17 +355,21 @@ export default function TicketDetailPage({
|
||||
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||
|
||||
const [watchers, setWatchers] = useState<Watcher[]>([]);
|
||||
const [watcherToggling, setWatcherToggling] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([
|
||||
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes, watchersRes] = await Promise.all([
|
||||
getTicket(id),
|
||||
getTicketTransactions(id),
|
||||
getQueues(),
|
||||
getLifecycles(),
|
||||
getUsers(),
|
||||
getTeams(),
|
||||
getWatchers(id),
|
||||
]);
|
||||
|
||||
if (ticketRes.error) {
|
||||
@@ -404,6 +426,13 @@ export default function TicketDetailPage({
|
||||
setLinks(linksRes.data ?? []);
|
||||
}
|
||||
|
||||
if (watchersRes.error) {
|
||||
// watchers are non-critical, don't surface as error
|
||||
console.warn('Failed to load watchers:', watchersRes.error);
|
||||
} else {
|
||||
setWatchers(watchersRes.data ?? []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
@@ -458,6 +487,29 @@ export default function TicketDetailPage({
|
||||
setScripResults(null);
|
||||
};
|
||||
|
||||
const handleToggleWatch = async () => {
|
||||
if (!ticket || watcherToggling) return;
|
||||
setWatcherToggling(true);
|
||||
|
||||
const currentUserId = getCurrentUserId();
|
||||
const isWatching = watchers.some((w) => w.user_id === currentUserId);
|
||||
|
||||
if (isWatching) {
|
||||
const { error } = await removeWatcher(id, currentUserId);
|
||||
if (!error) {
|
||||
setWatchers((prev) => prev.filter((w) => w.user_id !== currentUserId));
|
||||
}
|
||||
} else {
|
||||
const { data, error } = await addWatcher(id);
|
||||
if (!error && data) {
|
||||
const { data: refreshed } = await getWatchers(id);
|
||||
if (refreshed) setWatchers(refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
setWatcherToggling(false);
|
||||
};
|
||||
|
||||
const refreshTransactions = async () => {
|
||||
const txRes = await getTicketTransactions(id);
|
||||
if (txRes.data) setTransactions(txRes.data);
|
||||
@@ -784,6 +836,31 @@ export default function TicketDetailPage({
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock3Icon className="h-3.5 w-3.5" />
|
||||
Updated {formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
|
||||
{(() => {
|
||||
const currentId = getCurrentUserId();
|
||||
const isWatching = watchers.some((w) => w.user_id === currentId);
|
||||
return (
|
||||
<button
|
||||
onClick={() => void handleToggleWatch()}
|
||||
disabled={watcherToggling}
|
||||
className={cn(
|
||||
"ml-2 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
|
||||
isWatching
|
||||
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
|
||||
: "border-border/50 text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
||||
)}
|
||||
title={isWatching ? "Unwatch ticket" : "Watch ticket"}
|
||||
type="button"
|
||||
>
|
||||
{isWatching ? (
|
||||
<BellOffIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<BellIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isWatching ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -922,6 +999,31 @@ export default function TicketDetailPage({
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{replyMode === "internal" ? "Visible to staff only" : "Public correspondence"}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex rounded-md border border-border bg-muted/55 p-1">
|
||||
<button
|
||||
onClick={() => setComposerMode("write")}
|
||||
className={cn(
|
||||
"h-7 rounded px-2.5 text-xs font-semibold transition-colors",
|
||||
composerMode === "write"
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setComposerMode("preview")}
|
||||
className={cn(
|
||||
"h-7 rounded px-2.5 text-xs font-semibold transition-colors",
|
||||
composerMode === "preview"
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
@@ -947,16 +1049,27 @@ export default function TicketDetailPage({
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(event) => {
|
||||
setReplyText(event.target.value);
|
||||
setSendError(null);
|
||||
}}
|
||||
placeholder={replyMode === "internal" ? "Add internal context..." : "Write a reply..."}
|
||||
rows={3}
|
||||
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
{composerMode === "write" ? (
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(event) => {
|
||||
setReplyText(event.target.value);
|
||||
setSendError(null);
|
||||
}}
|
||||
placeholder={replyMode === "internal" ? "Add internal context..." : "Write a reply..."}
|
||||
rows={3}
|
||||
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="min-h-24 flex-1 rounded-md border border-border/50 bg-card/90 px-3 py-2 text-sm leading-6 shadow-sm overflow-y-auto prose prose-sm max-w-none text-foreground/90 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_code]:rounded [&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_code]:font-mono [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border/50 [&_pre]:bg-muted/40 [&_pre]:p-3 [&_pre]:text-xs [&_pre]:overflow-x-auto [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:underline [&_p]:leading-relaxed [&_hr]:border-border/50"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: replyText.trim()
|
||||
? renderMarkdown(replyText)
|
||||
: '<p class="text-muted-foreground italic text-xs">Nothing to preview</p>'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={timeMinutes}
|
||||
@@ -1024,6 +1137,16 @@ export default function TicketDetailPage({
|
||||
<p className="mt-1.5 text-[10px] text-muted-foreground/70">Uploading files...</p>
|
||||
)}
|
||||
{sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>}
|
||||
{composerMode === "write" && (
|
||||
<p className="mt-2 text-[10px] text-muted-foreground/60">
|
||||
<span className="font-medium">Markdown</span> supported:{" "}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">**bold**</code>{" "}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">*italic*</code>{" "}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">[link](url)</code>{" "}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">`code`</code>{" "}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">```block```</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
@@ -1174,6 +1297,27 @@ export default function TicketDetailPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Watchers */}
|
||||
{watchers.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
Watchers ({watchers.length})
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watchers.map((w) => (
|
||||
<span
|
||||
key={w.id}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold text-white"
|
||||
style={{ backgroundColor: getInitialColor(w.user?.username ?? w.user_id) }}
|
||||
title={w.user?.username ?? w.user_id}
|
||||
>
|
||||
{getInitial(w.user?.username ?? w.user_id)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Details — simple key-value lines */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Details</h2>
|
||||
@@ -1196,6 +1340,39 @@ export default function TicketDetailPage({
|
||||
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* SLA indicators */}
|
||||
{ticket.sla_resolution_deadline && (
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">SLA</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
ticket.sla_breached === 'resolution' || ticket.sla_breached === 'both'
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: new Date(ticket.sla_resolution_deadline) < new Date(Date.now() + 60 * 60 * 1000)
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-emerald-600 dark:text-emerald-400"
|
||||
)}>
|
||||
{ticket.sla_breached === 'resolution' || ticket.sla_breached === 'both'
|
||||
? 'Breached'
|
||||
: `Due ${formatDistanceToNow(new Date(ticket.sla_resolution_deadline), { addSuffix: true })}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ticket.sla_response_deadline && !ticket.sla_breached && (
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Response</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
new Date(ticket.sla_response_deadline) < new Date()
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: new Date(ticket.sla_response_deadline) < new Date(Date.now() + 60 * 60 * 1000)
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-emerald-600 dark:text-emerald-400"
|
||||
)}>
|
||||
Due {formatDistanceToNow(new Date(ticket.sla_response_deadline), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Time worked</span>
|
||||
<span className="text-foreground">
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
AttachmentUploadResult,
|
||||
TicketLink,
|
||||
LoginResult,
|
||||
Watcher,
|
||||
SlaPolicy,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = "/api";
|
||||
@@ -181,6 +183,52 @@ export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean
|
||||
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Watchers
|
||||
|
||||
export async function getWatchers(ticketId: number): Promise<{ data: Watcher[] | null; error: string | null }> {
|
||||
return request<Watcher[]>(`/tickets/${ticketId}/watchers`);
|
||||
}
|
||||
|
||||
export async function addWatcher(ticketId: number, userId?: string): Promise<{ data: Watcher | null; error: string | null }> {
|
||||
return request<Watcher>(`/tickets/${ticketId}/watchers`, { method: "POST", body: JSON.stringify(userId ? { user_id: userId } : {}) });
|
||||
}
|
||||
|
||||
export async function removeWatcher(ticketId: number, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/tickets/${ticketId}/watchers/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// SLA Policies
|
||||
|
||||
export async function getSlaPolicies(): Promise<{ data: SlaPolicy[] | null; error: string | null }> {
|
||||
return request<SlaPolicy[]>("/sla-policies");
|
||||
}
|
||||
|
||||
export async function createSlaPolicy(data: {
|
||||
name: string;
|
||||
queue_id?: string;
|
||||
description?: string;
|
||||
response_time_minutes?: number;
|
||||
resolution_time_minutes?: number;
|
||||
disabled?: boolean;
|
||||
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
|
||||
return request<SlaPolicy>("/sla-policies", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateSlaPolicy(id: string, data: {
|
||||
name?: string;
|
||||
queue_id?: string | null;
|
||||
description?: string;
|
||||
response_time_minutes?: number | null;
|
||||
resolution_time_minutes?: number | null;
|
||||
disabled?: boolean;
|
||||
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
|
||||
return request<SlaPolicy>(`/sla-policies/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteSlaPolicy(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/sla-policies/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
||||
return request<Queue[]>("/queues");
|
||||
}
|
||||
|
||||
30
web/src/lib/markdown.ts
Normal file
30
web/src/lib/markdown.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Render markdown string to sanitized HTML.
|
||||
* Strips raw HTML tags for XSS safety.
|
||||
*/
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
// Strip raw HTML tags for XSS safety
|
||||
const sanitized = markdown.replace(/<[^>]*>/g, '');
|
||||
|
||||
try {
|
||||
const html = marked.parse(sanitized) as string;
|
||||
return html;
|
||||
} catch {
|
||||
// Fallback: escape and wrap in <p>
|
||||
const escaped = sanitized
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<p>${escaped}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export interface Ticket {
|
||||
updated_at: string;
|
||||
started_at: string | null;
|
||||
resolved_at: string | null;
|
||||
sla_response_deadline?: string | null;
|
||||
sla_resolution_deadline?: string | null;
|
||||
sla_breached?: string | null;
|
||||
custom_fields?: CustomFieldValue[];
|
||||
blocked_by?: Array<{ id: number; subject: string; status: string }>;
|
||||
}
|
||||
@@ -262,6 +265,25 @@ export interface Notification {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Watcher {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
user?: { id: string; username: string; email: string | null } | null;
|
||||
}
|
||||
|
||||
export interface SlaPolicy {
|
||||
id: string;
|
||||
queue_id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
response_time_minutes: number | null;
|
||||
resolution_time_minutes: number | null;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user