updated tickets

This commit is contained in:
JiriUhlir
2026-08-03 11:45:35 +02:00
parent 9a0c67af08
commit 52b190bfbc
28 changed files with 3228 additions and 241 deletions
+2
View File
@@ -18,6 +18,7 @@ const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
@@ -61,6 +62,7 @@ export default function App() {
<Route path="automatizace/:id" element={<AutomationDetail />} />
<Route path="konektory" element={<Connectors />} />
<Route path="tickety" element={<Tickets />} />
<Route path="tickety/:id" element={<TicketDetail />} />
<Route path="incidenty" element={<Incidents />} />
<Route path="nastaveni" element={<Settings />} />
<Route path="*" element={<NotFound />} />
+2 -1
View File
@@ -1,4 +1,4 @@
import { AlarmClock, CheckCircle2, LifeBuoy, Webhook, Workflow, X } from 'lucide-react';
import { AlarmClock, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
@@ -12,6 +12,7 @@ const MAX_VISIBLE = 4;
const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
'ticket.created': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.updated': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.assigned': { icon: UserCheck, tone: 'text-accent-300 bg-accent-500/12' },
'ticket.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
'incident.started': { icon: AlarmClock, tone: 'text-danger-400 bg-danger-500/12' },
'incident.updated': { icon: AlarmClock, tone: 'text-warn-400 bg-warn-500/12' },
@@ -25,6 +25,15 @@ const priorities = [
{ value: 'critical', label: 'Kritická' },
] as const;
/** Kanal urcuje nejen text, ale i to, jak bude vypadat log ticketu. */
const channels = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'email', label: 'E-mail' },
{ value: 'voice', label: 'Hlasová linka' },
{ value: 'form', label: 'Webový formulář' },
{ value: 'portal', label: 'Portál (ručně)' },
] as const;
const severities = [
{ value: 'sev3', label: 'SEV3 - menší' },
{ value: 'sev2', label: 'SEV2 - vážný' },
@@ -46,8 +55,10 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
const [result, setResult] = useState<Result | null>(null);
const [subject, setSubject] = useState('');
const [requester, setRequester] = useState('');
const [contact, setContact] = useState('');
const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal');
const [channel, setChannel] = useState<(typeof channels)[number]['value']>('whatsapp');
const [knownCustomer, setKnownCustomer] = useState(true);
const [title, setTitle] = useState('');
const [service, setService] = useState('');
@@ -76,8 +87,10 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
void run('ticket.created', {
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
...(subject.trim() ? { subject: subject.trim() } : {}),
...(requester.trim() ? { requester: requester.trim() } : {}),
...(contact.trim() ? { contact: contact.trim() } : {}),
channel,
priority,
knownCustomer,
});
setSubject('');
}
@@ -100,28 +113,30 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
description="Vyvolá skutečnou událost, aby bylo vidět, jak dashboard reaguje živě."
>
<div className="max-h-[65vh] space-y-5 overflow-y-auto p-5">
<Panel icon={LifeBuoy} title="Nový ticket">
<Panel icon={LifeBuoy} title="Nový ticket z kanálu">
<form onSubmit={submitTicket} className="space-y-3">
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<div className="flex flex-wrap gap-3">
<input
value={requester}
onChange={(event) => setRequester(event.target.value)}
placeholder="Zadavatel"
className={cn(inputClass, 'min-w-0 flex-1')}
/>
<select
value={channel}
onChange={(event) =>
setChannel(event.target.value as (typeof channels)[number]['value'])
}
aria-label="Kanál"
className={cn(inputClass, 'w-48')}
>
{channels.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
<select
value={priority}
onChange={(event) =>
setPriority(event.target.value as (typeof priorities)[number]['value'])
}
aria-label="Priorita"
className={cn(inputClass, 'w-40')}
className={cn(inputClass, 'min-w-0 flex-1')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
@@ -130,6 +145,33 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
))}
</select>
</div>
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<input
value={contact}
onChange={(event) => setContact(event.target.value)}
placeholder="Kdo píše (nepovinné)"
className={inputClass}
/>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-white/60">
<input
type="checkbox"
checked={knownCustomer}
onChange={(event) => setKnownCustomer(event.target.checked)}
className="mt-0.5 size-4 accent-cyan-400"
/>
<span>
Zákazníka se podaří dohledat v CRM
<span className="block text-xs text-white/35">
Když vypnete, ticket zůstane bez firmy i bez řešitele a v logu bude vidět,
proč se přiřazení přeskočilo.
</span>
</span>
</label>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'ticket.created' ? 'Zakládám...' : 'Založit ticket'}
+34 -1
View File
@@ -1,8 +1,22 @@
import { AlertTriangle, CheckCircle2, CircleDot, Clock, Search, ShieldAlert } from 'lucide-react';
import {
AlertTriangle,
CheckCircle2,
CircleDot,
Clock,
FileInput,
LayoutDashboard,
Mail,
MessageCircle,
PhoneCall,
Search,
ShieldAlert,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Badge, type BadgeTone } from '@/components/ui/Badge';
import type {
IncidentSeverity,
IncidentStatus,
TicketChannel,
TicketPriority,
TicketStatus,
} from '@/types/dashboard';
@@ -26,6 +40,15 @@ const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone
critical: { label: 'Kritická', tone: 'danger' },
};
/** Kanal nese ikonu, ne barvu - neni to stav, nema co kricet. */
const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon }> = {
whatsapp: { label: 'WhatsApp', icon: MessageCircle },
email: { label: 'E-mail', icon: Mail },
voice: { label: 'Hlasová linka', icon: PhoneCall },
form: { label: 'Formulář', icon: FileInput },
portal: { label: 'Portál', icon: LayoutDashboard },
};
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
@@ -60,6 +83,16 @@ export function TicketPriorityBadge({ priority }: { priority: TicketPriority })
);
}
export function TicketChannelBadge({ channel }: { channel: TicketChannel }) {
const { label, icon: Icon } = ticketChannelMap[channel];
return (
<Badge tone="neutral">
<Icon className="size-3.5" />
{label}
</Badge>
);
}
export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity }) {
const { label, tone } = incidentSeverityMap[severity];
return (
@@ -0,0 +1,169 @@
import { AlertCircle, CheckCircle2, GitBranch, Info, MinusCircle, Zap } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useMemo } from 'react';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { formatDateTime, formatDuration } from '@/lib/format';
import type { Connector, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
/**
* Log ticketu jako strom. Vetev podminky je zanorena pod ni,
* takze je videt, ktera cast behu se vubec nespustila.
*
* Odpoved sluzby se zobrazuje rovnou, ne po rozkliknuti - kvuli ni se
* do logu chodi a schovavat ji za dalsi klik nema smysl.
*/
const statusMeta: Record<TraceStatus, { icon: LucideIcon; tone: string; line: string }> = {
ok: { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12', line: 'border-ok-400/25' },
error: { icon: AlertCircle, tone: 'text-danger-400 bg-danger-500/12', line: 'border-danger-400/30' },
skipped: { icon: MinusCircle, tone: 'text-white/40 bg-white/5', line: 'border-white/10' },
info: { icon: Info, tone: 'text-brand-300 bg-brand-500/12', line: 'border-brand-400/20' },
};
interface TraceNode extends TicketTraceEntry {
children: TraceNode[];
}
/** Ze seznamu s `parentId` udela strom. Zaznam s neznamym rodicem se nesmi ztratit. */
function buildTree(entries: TicketTraceEntry[]): TraceNode[] {
const byId = new Map<string, TraceNode>();
for (const entry of entries) {
byId.set(entry.id, { ...entry, children: [] });
}
const roots: TraceNode[] = [];
for (const entry of entries) {
const node = byId.get(entry.id);
if (!node) continue;
if (entry.parentId === null) {
roots.push(node);
continue;
}
const parent = byId.get(entry.parentId);
if (!parent) {
console.warn(`[trace] zaznam ${entry.id} ma neznameho rodice ${entry.parentId}`);
roots.push(node);
continue;
}
parent.children.push(node);
}
return roots;
}
export function TicketTrace({
entries,
connectors,
}: {
entries: TicketTraceEntry[];
connectors: Connector[];
}) {
const tree = useMemo(() => buildTree(entries), [entries]);
if (entries.length === 0) {
return (
<p className="rounded-xl border border-dashed border-ink-600/70 px-4 py-6 text-center text-sm text-white/40">
K tomuto ticketu zatím není žádný záznam.
</p>
);
}
return (
<ol className="space-y-2.5">
{tree.map((node) => (
<TraceRow key={node.id} node={node} connectors={connectors} />
))}
</ol>
);
}
function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector[] }) {
const meta = statusMeta[node.status];
const Icon = node.kind === 'condition' ? GitBranch : node.kind === 'trigger' ? Zap : meta.icon;
const service = describeService(node, connectors);
return (
<li>
<div
className={cn(
'rounded-xl border bg-ink-850/40 p-3.5',
node.status === 'error' ? 'border-danger-400/30' : 'border-ink-600/50',
)}
>
<div className="flex items-start gap-3">
<span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg', meta.tone)}>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm font-medium text-white/85">{node.label}</p>
{service && (
<span className="font-mono text-xs text-brand-300/80">{service}</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/35">
<span>{formatDateTime(node.at)}</span>
{node.durationMs !== null && <span>{formatDuration(node.durationMs)}</span>}
{node.status === 'error' && <span className="text-danger-400">selhalo</span>}
{node.status === 'skipped' && <span>neproběhlo</span>}
</div>
{node.response && (
<pre className="mt-2.5 overflow-x-auto rounded-lg border border-ink-600/60 bg-ink-900/80 px-3 py-2 font-mono text-xs whitespace-pre-wrap text-white/60">
{node.response}
</pre>
)}
</div>
</div>
</div>
{node.children.length > 0 && (
<ol className={cn('mt-2.5 space-y-2.5 border-l-2 pl-4 sm:pl-6', meta.line)}>
{node.children.map((child) => (
<TraceRow key={child.id} node={child} connectors={connectors} />
))}
</ol>
)}
</li>
);
}
/** "RAYNET CRM / Dohledani kontaktu" - kdyz konektor v katalogu neni, aspon jeho ID. */
function describeService(node: TraceNode, connectors: Connector[]): string | null {
if (!node.connectorId) return null;
const connector = connectors.find((c) => c.id === node.connectorId);
if (!connector) return `${node.connectorId}${node.operationId ? ` / ${node.operationId}` : ''}`;
if (!node.operationId) return connector.name;
const pool = node.kind === 'trigger' ? connector.triggers : connector.actions;
const operation = pool.find((op) => op.id === node.operationId);
return `${connector.name} / ${operation?.name ?? node.operationId}`;
}
/** Male shrnuti nad logem - kolik kroku, kolik chyb. */
export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
const failed = entries.filter((e) => e.status === 'error').length;
const skipped = entries.filter((e) => e.status === 'skipped').length;
const services = new Set(entries.map((e) => e.connectorId).filter(Boolean)).size;
return (
<p className="text-xs text-white/40">
{entries.length} {entries.length === 1 ? 'záznam' : entries.length <= 4 ? 'záznamy' : 'záznamů'}
{services > 0 && ` · ${services} ${services === 1 ? 'služba' : services <= 4 ? 'služby' : 'služeb'}`}
{failed > 0 && <span className="text-danger-400"> · {failed} selhalo</span>}
{skipped > 0 && ` · ${skipped} neproběhlo`}
</p>
);
}
/** Ikona konektoru pro pouziti nad logem (napr. u kanalu ticketu). */
export function serviceIcon(connectorId: string, connectors: Connector[]): LucideIcon | null {
const connector = connectors.find((c) => c.id === connectorId);
return connector ? connectorIcon(connector.icon) : null;
}
@@ -0,0 +1,123 @@
import { AlertTriangle, Inbox, Users } from 'lucide-react';
import { cn } from '@/lib/cn';
import { formatRelative } from '@/lib/format';
import type { Workload } from '@/types/dashboard';
/**
* Prehled nad firmou: kdo ma kolik ticketu u sebe a kolik jich ceka ve fronte.
*
* Radek je zaroven filtr seznamu - jinak by to byl jen obrazek.
* Bez tohohle pohledu nejde poznat, ze jeden clovek utahuje pulku servicedesku.
*/
export function TicketWorkload({
workload,
active,
onSelect,
}: {
workload: Workload;
/** ID resitele, 'unassigned', nebo null pro "nefiltrovano". */
active: string | null;
onSelect: (value: string | null) => void;
}) {
const busiest = workload.rows[0]?.open ?? 0;
return (
<section className="glass rounded-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
<Users className="size-4 text-brand-300" />
Kdo to u sebe
</h2>
<span className="text-xs text-white/40">{workload.openTotal} nevyřešených</span>
</div>
<ul className="mt-4 space-y-1.5">
{workload.rows.map((row) => {
const isActive = active === row.person.id;
// Sirka pruhu je vzdy proti nejvytizenejsimu, ne proti kapacite -
// jde o porovnani lidi mezi sebou.
const width = busiest === 0 ? 0 : Math.round((row.open / busiest) * 100);
return (
<li key={row.person.id}>
<button
type="button"
onClick={() => onSelect(isActive ? null : row.person.id)}
aria-pressed={isActive}
className={cn(
'w-full rounded-xl border px-3.5 py-2.5 text-left transition-colors',
isActive
? 'border-brand-400/50 bg-brand-500/10'
: 'border-transparent hover:border-ink-600/70 hover:bg-white/[0.03]',
)}
>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-sm font-medium text-white/85">
{row.person.name}
</span>
<span
className={cn(
'shrink-0 font-mono text-sm tabular-nums',
row.overloaded ? 'text-warn-400' : 'text-white/60',
)}
>
{row.open}
</span>
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-white/8">
<div
className={cn(
'h-full rounded-full',
row.overloaded ? 'bg-warn-400/70' : 'bg-brand-400/70',
)}
style={{ width: `${width}%` }}
/>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-xs text-white/35">
<span>{row.person.role}</span>
{row.critical > 0 && (
<span className="flex items-center gap-1 text-danger-400">
<AlertTriangle className="size-3" />
{row.critical} kritick{row.critical === 1 ? 'ý' : 'é'}
</span>
)}
{row.oldestOpenAt && <span>nejstarší {formatRelative(row.oldestOpenAt)}</span>}
{row.overloaded && (
<span className="text-warn-400">nad kapacitu {row.person.capacity}</span>
)}
</div>
</button>
</li>
);
})}
</ul>
<button
type="button"
onClick={() => onSelect(active === 'unassigned' ? null : 'unassigned')}
aria-pressed={active === 'unassigned'}
className={cn(
'mt-3 flex w-full items-center justify-between gap-3 rounded-xl border border-dashed px-3.5 py-2.5 text-sm transition-colors',
active === 'unassigned'
? 'border-warn-400/50 bg-warn-500/8 text-white'
: 'border-ink-600/70 text-white/55 hover:border-warn-400/50 hover:text-white',
)}
>
<span className="flex items-center gap-2">
<Inbox className="size-4" />
Ve frontě, bez řešitele
</span>
<span
className={cn(
'font-mono tabular-nums',
workload.unassigned > 0 ? 'text-warn-400' : 'text-white/40',
)}
>
{workload.unassigned}
</span>
</button>
</section>
);
}
@@ -29,27 +29,12 @@ export function TriggerConfig({
const isWebhook = trigger.connectorId === 'webhook';
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
const editable = operation?.customPayload === true;
function addField() {
onChangeFields([
...trigger.fields,
{ id: newFieldId(), name: '', type: 'string', required: true },
]);
}
function updateField(id: string, patch: Partial<TriggerField>) {
onChangeFields(trigger.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}
function removeField(id: string) {
onChangeFields(trigger.fields.filter((f) => f.id !== id));
}
const duplicates = new Set(
trigger.fields
.map((f) => f.name.trim())
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
);
/**
* Sluzba, ktera si data urcuje sama (e-mail, WhatsApp, ticket). Parametry
* pak nejsou na uzivateli - server je pri ulozeni stejne prepise katalogem,
* takze by editovatelne pole jen lhalo.
*/
const provided = operation?.providedFields;
return (
<div className="mt-4 space-y-4 border-t border-ink-600/50 pt-4">
@@ -62,34 +47,75 @@ export function TriggerConfig({
/>
)}
<div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
<p className="mt-0.5 text-xs text-white/45">
{editable
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
</p>
</div>
<button
type="button"
onClick={addField}
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
>
<Plus className="size-3.5" />
Přidat parametr
</button>
</div>
{provided ? (
<ProvidedFields fields={provided} serviceName={connector?.name ?? 'Tato služba'} />
) : (
<CustomFields fields={trigger.fields} editable={editable} onChange={onChangeFields} />
)}
</div>
);
}
{trigger.fields.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
Zatím žádné parametry. Bez nich nelze přidat podmínku nebylo by podle čeho
se rozhodovat.
/**
* Parametry, ktere si deklaruje uzivatel (webhook, formular).
* Podminky se odkazuji na `field.id`, prejmenovani je tedy nerozbije.
*/
function CustomFields({
fields,
editable,
onChange,
}: {
fields: TriggerField[];
editable: boolean;
onChange: (fields: TriggerField[]) => void;
}) {
function addField() {
onChange([...fields, { id: newFieldId(), name: '', type: 'string', required: true }]);
}
function updateField(id: string, patch: Partial<TriggerField>) {
onChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}
function removeField(id: string) {
onChange(fields.filter((f) => f.id !== id));
}
const duplicates = new Set(
fields
.map((f) => f.name.trim())
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
);
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
<p className="mt-0.5 text-xs text-white/45">
{editable
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
</p>
) : (
<ul className="mt-3 space-y-2">
{trigger.fields.map((field) => {
</div>
<button
type="button"
onClick={addField}
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
>
<Plus className="size-3.5" />
Přidat parametr
</button>
</div>
{fields.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
Zatím žádné parametry. Bez nich nelze přidat podmínku nebylo by podle čeho
se rozhodovat.
</p>
) : (
<ul className="mt-3 space-y-2">
{fields.map((field) => {
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
return (
@@ -154,10 +180,47 @@ export function TriggerConfig({
)}
</li>
);
})}
</ul>
)}
</div>
})}
</ul>
)}
</div>
);
}
/**
* Parametry, ktere spoustec predava sam. Jen ke cteni - jsou dane katalogem
* a menit je z builderu by bylo mateni. Podminky se na ne odkazuji stejne
* jako na rucne deklarovane.
*/
function ProvidedFields({
fields,
serviceName,
}: {
fields: TriggerField[];
serviceName: string;
}) {
return (
<div>
<h3 className="text-sm font-semibold text-white">Co spouštěč předá dál</h3>
<p className="mt-0.5 text-xs text-white/45">
{serviceName} posílá tyto hodnoty sama. Nastavovat se nedají, ale můžete nad nimi
stavět podmínky.
</p>
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
{fields.map((field) => (
<li
key={field.id}
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-850/50 px-3 py-2"
>
<span className="min-w-0 truncate font-mono text-sm text-brand-200">{field.name}</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-white/40">
{fieldTypeLabels[field.type]}
{field.required && <span className="text-white/25">povinný</span>}
</span>
</li>
))}
</ul>
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ import {
LifeBuoy,
Mail,
Megaphone,
MessageCircle,
MessageSquare,
MousePointer,
MousePointerClick,
@@ -45,6 +46,7 @@ const icons: Record<string, LucideIcon> = {
LifeBuoy,
Mail,
Megaphone,
MessageCircle,
MessageSquare,
MousePointer,
MousePointerClick,
+24 -2
View File
@@ -77,6 +77,29 @@ export default function AutomationDetail() {
setSavedAt(null);
}, []);
/** Parametry, ktere ma mit spoustec po jeho zmene. */
function fieldsForTrigger(connectorId: string, operationId: string): TriggerField[] {
const provided = findTrigger(connectorId, operationId)?.providedFields;
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
// tady je nastavime hned, aby slo rovnou stavet podminky.
if (provided) return provided;
const previous = flow.trigger
? findTrigger(flow.trigger.connectorId, flow.trigger.operationId)
: undefined;
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
if (previous?.providedFields) return [];
// Jinak drzime uz nadeklarovane parametry, aby se nezahodila prace.
return flow.trigger?.fields ?? [];
}
function findTrigger(connectorId: string, operationId: string) {
return connectors
.find((connector) => connector.id === connectorId)
?.triggers.find((operation) => operation.id === operationId);
}
function handlePick(connectorId: string, operationId: string) {
if (!picker) {
console.warn('[builder] vyber potvrzen bez otevreneho cile');
@@ -84,13 +107,12 @@ export default function AutomationDetail() {
}
if (picker.mode === 'trigger') {
// Pri zmene spoustece drzime uz nadeklarovane parametry, aby se nezahodila prace.
changeFlow({
...flow,
trigger: {
connectorId,
operationId,
fields: flow.trigger?.fields ?? [],
fields: fieldsForTrigger(connectorId, operationId),
webhookToken: flow.trigger?.webhookToken,
},
});
+16 -5
View File
@@ -9,7 +9,12 @@ import { Button } from '@/components/ui/Button';
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { DashboardSummary, Incident, ListResponse, Ticket } from '@/types/dashboard';
import type {
DashboardSummary,
Incident,
ListResponse,
TicketListResponse,
} from '@/types/dashboard';
export default function Overview() {
usePageMeta({ title: 'Přehled — portál Automia' });
@@ -30,8 +35,8 @@ export default function Overview() {
'webhook.received',
],
});
const tickets = useApiQuery<ListResponse<Ticket>>('/api/dashboard/tickets', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
const tickets = useApiQuery<TicketListResponse>('/api/dashboard/tickets', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
@@ -126,9 +131,15 @@ export default function Overview() {
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white/80">{ticket.subject}</p>
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<p className="mt-0.5 text-xs text-white/40">
{ticket.requester} · {formatRelative(ticket.updatedAt)}
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
{formatRelative(ticket.updatedAt)}
</p>
</div>
<TicketStatusBadge status={ticket.status} />
+319
View File
@@ -0,0 +1,319 @@
import { AlertCircle, ArrowLeft, Building2, MessageSquarePlus, ScrollText, UserCheck } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import {
TicketChannelBadge,
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
ConnectorCatalog,
PeopleResponse,
TicketDetail as Detail,
TicketStatus,
} from '@/types/dashboard';
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: 'new', label: 'Nový' },
{ value: 'open', label: 'V řešení' },
{ value: 'waiting', label: 'Čeká na klienta' },
{ value: 'resolved', label: 'Vyřešeno' },
];
const selectClass =
'rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white focus:border-brand-400/70 focus:outline-none';
export default function TicketDetail() {
const { id = '' } = useParams();
const ticket = useApiQuery<Detail>(`/api/dashboard/tickets/${id}`, {
refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [comment, setComment] = useState('');
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} — portál Automia` });
// Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu.
useEffect(() => {
setActionError(null);
}, [ticket.data?.updatedAt]);
/** Kazda zmena jde pres server a nasledne se ticket nacte znovu. Vraci uspech. */
async function mutate(path: string, body: unknown, fallback: string): Promise<boolean> {
setBusy(true);
setActionError(null);
try {
await apiFetch<unknown>(`/api/dashboard/tickets/${id}${path}`, { method: 'POST', body });
ticket.reload();
return true;
} catch (err) {
const message = err instanceof Error ? err.message : fallback;
console.error(`[ticket] ${path} selhalo:`, err);
setActionError(message);
return false;
} finally {
setBusy(false);
}
}
async function submitComment(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = comment.trim();
if (text.length < 2) return;
// Pri chybe text necháme v poli, aby ho uzivatel nemusel psat znovu.
if (await mutate('/comment', { text }, 'Komentář se nepodařilo uložit.')) {
setComment('');
}
}
return (
<div className="space-y-6">
<Link
to="/dashboard/tickety"
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na tickety
</Link>
<DataState
loading={ticket.loading || catalog.loading || people.loading}
error={ticket.error ?? catalog.error ?? people.error}
onRetry={() => {
ticket.reload();
catalog.reload();
people.reload();
}}
>
{ticket.data && (
<div className="space-y-6">
<header>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-white/35">{ticket.data.id}</span>
<TicketChannelBadge channel={ticket.data.channel} />
<TicketStatusBadge status={ticket.data.status} />
<TicketPriorityBadge priority={ticket.data.priority} />
{ticket.data.customer.id === null && (
<Badge tone="warn">
<AlertCircle className="size-3.5" />
Zákazník nedohledán
</Badge>
)}
</div>
<h1 className="mt-2 text-2xl font-bold text-white">{ticket.data.subject}</h1>
</header>
{actionError && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{actionError}
</p>
)}
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
<ScrollText className="size-4 text-brand-300" />
Průběh a log
</h2>
<TraceSummary entries={ticket.data.trace} />
</div>
<TicketTrace
entries={ticket.data.trace}
connectors={catalog.data?.items ?? []}
/>
<form
onSubmit={(event) => void submitComment(event)}
className="mt-5 border-t border-ink-600/50 pt-5"
>
<label
htmlFor="ticket-comment"
className="text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Přidat komentář
</label>
<div className="mt-2 flex flex-wrap gap-2">
<input
id="ticket-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Co jste zjistili nebo udělali…"
className="min-w-0 flex-1 rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
<Button type="submit" size="sm" disabled={busy || comment.trim().length < 2}>
<MessageSquarePlus className="size-4" />
Zapsat
</Button>
</div>
<p className="mt-2 text-xs text-white/35">
Komentář se zapíše do stejné časové osy jako běh automatizace.
</p>
</form>
</section>
<aside className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<UserCheck className="size-4 text-brand-300" />
Řešitel
</h2>
<p
className={cn(
'mt-3 text-sm',
ticket.data.assignee ? 'text-white/85' : 'text-warn-400',
)}
>
{ticket.data.assignee ? ticket.data.assignee.name : 'Ve frontě, bez řešitele'}
</p>
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<select
id="ticket-assignee"
value={ticket.data.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate(
'/assign',
{ assigneeId: event.target.value === '' ? null : event.target.value },
'Přiřazení se nepodařilo.',
)
}
className={cn(selectClass, 'mt-3 w-full')}
>
<option value="" className="bg-ink-850">
Vrátit do fronty
</option>
{(people.data?.items ?? []).map((person) => (
<option key={person.id} value={person.id} className="bg-ink-850">
{person.name}, {person.role}
</option>
))}
</select>
<label
htmlFor="ticket-status"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Stav
</label>
<select
id="ticket-status"
value={ticket.data.status}
disabled={busy}
onChange={(event) =>
void mutate(
'/status',
{ status: event.target.value },
'Změna stavu se nepodařila.',
)
}
className={cn(selectClass, 'mt-2 w-full')}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
</div>
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<Building2 className="size-4 text-brand-300" />
Zákazník
</h2>
<dl className="mt-3 space-y-2.5 text-sm">
<Row
label="Firma"
value={ticket.data.customer.company}
warn={ticket.data.customer.id === null}
/>
<Row label="Kontakt" value={ticket.data.customer.contact} />
<Row label="Odpověď na" value={ticket.data.customer.reply} />
<Row
label="ID v CRM"
value={ticket.data.customer.id ?? 'nedohledáno'}
warn={ticket.data.customer.id === null}
/>
</dl>
{ticket.data.customer.id === null && (
<p className="mt-3 rounded-lg border border-warn-400/30 bg-warn-500/8 px-3 py-2.5 text-xs leading-relaxed text-white/60">
Firmu se nepodařilo dohledat v CRM. Ticket proto zůstal bez řešitele,
není podle čeho určit garanta.
</p>
)}
</div>
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Původ</h2>
<dl className="mt-3 space-y-2.5 text-sm">
<Row label="Založeno" value={formatDateTime(ticket.data.createdAt)} />
<Row label="Poslední změna" value={formatDateTime(ticket.data.updatedAt)} />
<Row
label="Automatizace"
value={ticket.data.automationId ?? 'ručně'}
to={
ticket.data.automationId
? `/dashboard/automatizace/${ticket.data.automationId}`
: undefined
}
/>
</dl>
</div>
</aside>
</div>
</div>
)}
</DataState>
</div>
);
}
function Row({
label,
value,
warn,
to,
}: {
label: string;
value: string;
warn?: boolean;
to?: string;
}) {
return (
<div className="flex justify-between gap-3">
<dt className="shrink-0 text-white/40">{label}</dt>
<dd className={cn('text-right break-all', warn ? 'text-warn-400' : 'text-white/70')}>
{to ? (
<Link to={to} className="text-brand-300 transition-colors hover:text-brand-200">
{value}
</Link>
) : (
value
)}
</dd>
</div>
);
}
+282 -54
View File
@@ -1,75 +1,303 @@
import { ChevronRight, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import {
TicketChannelBadge,
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { ListResponse, Ticket } from '@/types/dashboard';
import type {
TicketChannel,
TicketListResponse,
TicketStatus,
Workload,
} from '@/types/dashboard';
/** Vsechny zmeny ticketu, po kterych ma smysl nacist data znovu. */
const ticketEvents = ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'] as const;
const statusFilters: Array<{ value: TicketStatus; label: string }> = [
{ value: 'new', label: 'Nové' },
{ value: 'open', label: 'V řešení' },
{ value: 'waiting', label: 'Čeká na klienta' },
{ value: 'resolved', label: 'Vyřešené' },
];
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'email', label: 'E-mail' },
{ value: 'voice', label: 'Hlasová linka' },
{ value: 'form', label: 'Formulář' },
{ value: 'portal', label: 'Portál' },
];
export default function Tickets() {
usePageMeta({ title: 'Tickety — portál Automia' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Ticket>>(
'/api/dashboard/tickets',
{ refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'] },
);
/** null = vsichni, 'me' = prihlaseny, 'unassigned' = fronta, jinak ID resitele. */
const [assignee, setAssignee] = useState<string | null>(null);
const [status, setStatus] = useState<TicketStatus | null>(null);
const [channel, setChannel] = useState<TicketChannel | null>(null);
const [query, setQuery] = useState('');
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
const path = useMemo(() => {
const params = new URLSearchParams();
if (assignee) params.set('assignee', assignee);
if (status) params.set('status', status);
if (channel) params.set('channel', channel);
const search = params.toString();
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
}, [assignee, status, channel]);
const tickets = useApiQuery<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
refetchOn: [...ticketEvents],
});
// Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi.
const items = useMemo(() => {
const needle = query.trim().toLowerCase();
const all = tickets.data?.items ?? [];
if (needle.length === 0) return all;
return all.filter((ticket) =>
[ticket.id, ticket.subject, ticket.customer.company, ticket.customer.contact]
.join(' ')
.toLowerCase()
.includes(needle),
);
}, [tickets.data, query]);
const meId = tickets.data?.meId ?? null;
const filtered = assignee !== null || status !== null || channel !== null;
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Tickety</h1>
<p className="mt-1 text-sm text-white/50">
Požadavky napříč kanály. Filtrování, komentáře a zakládání ticketů přidáme v další
iteraci.
Požadavky ze všech kanálů. Každý svého řešitele a dohledatelný průběh.
V detailu je vidět, co která služba vrátila.
</p>
</header>
<div className="glass overflow-hidden rounded-card">
<DataState
loading={loading}
error={error}
empty={data?.items.length === 0}
onRetry={reload}
emptyLabel="Žádné tickety."
>
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-5 py-3.5 font-medium">ID</th>
<th className="px-5 py-3.5 font-medium">Předmět</th>
<th className="px-5 py-3.5 font-medium">Zadavatel</th>
<th className="px-5 py-3.5 font-medium">Stav</th>
<th className="px-5 py-3.5 font-medium">Priorita</th>
<th className="px-5 py-3.5 font-medium">Řeší</th>
<th className="px-5 py-3.5 font-medium">Aktualizace</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{data?.items.map((ticket) => (
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
<td className="px-5 py-4 font-mono text-xs text-white/45">{ticket.id}</td>
<td className="px-5 py-4 text-white/85">{ticket.subject}</td>
<td className="px-5 py-4 text-white/55">{ticket.requester}</td>
<td className="px-5 py-4">
<TicketStatusBadge status={ticket.status} />
</td>
<td className="px-5 py-4">
<TicketPriorityBadge priority={ticket.priority} />
</td>
<td className="px-5 py-4 text-white/55">
{ticket.assignee ?? <span className="text-white/30">nepřiřazeno</span>}
</td>
<td className="px-5 py-4 text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</td>
</tr>
))}
</tbody>
</table>
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<div className="space-y-4">
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat podle čísla, předmětu nebo firmy…"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
Všechny
</Chip>
<Chip
active={assignee === 'me'}
onClick={() => setAssignee(assignee === 'me' ? null : 'me')}
disabled={meId === null}
title={
meId === null
? 'Přihlášený účet není v seznamu řešitelů, nemá tedy vlastní tickety.'
: undefined
}
>
Moje
</Chip>
<Chip
active={assignee === 'unassigned'}
onClick={() => setAssignee(assignee === 'unassigned' ? null : 'unassigned')}
>
Ve frontě
</Chip>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{statusFilters.map((item) => (
<Chip
key={item.value}
active={status === item.value}
onClick={() => setStatus(status === item.value ? null : item.value)}
>
{item.label}
</Chip>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{channelFilters.map((item) => (
<Chip
key={item.value}
active={channel === item.value}
onClick={() => setChannel(channel === item.value ? null : item.value)}
>
{item.label}
</Chip>
))}
</div>
</div>
</DataState>
<div className="glass overflow-hidden rounded-card">
<DataState
loading={tickets.loading}
error={tickets.error}
empty={items.length === 0}
onRetry={tickets.reload}
emptyLabel={
filtered || query.trim().length > 0
? 'Nic neodpovídá filtru.'
: 'Žádné tickety.'
}
>
<div className="overflow-x-auto">
<table className="w-full min-w-[58rem] text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-5 py-3.5 font-medium">ID</th>
<th className="px-5 py-3.5 font-medium">Předmět</th>
<th className="px-5 py-3.5 font-medium">Zákazník</th>
<th className="px-5 py-3.5 font-medium">Kanál</th>
<th className="px-5 py-3.5 font-medium">Stav</th>
<th className="px-5 py-3.5 font-medium">Priorita</th>
<th className="px-5 py-3.5 font-medium">Řeší</th>
<th className="px-5 py-3.5 font-medium">Aktualizace</th>
<th className="px-5 py-3.5">
<span className="sr-only">Detail</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{items.map((ticket) => (
<tr key={ticket.id} className="group transition-colors hover:bg-white/[0.03]">
<td className="px-5 py-4 font-mono text-xs text-white/45">{ticket.id}</td>
<td className="px-5 py-4">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="text-white/85 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
</td>
<td className="px-5 py-4">
{ticket.customer.id ? (
<span className="text-white/55">{ticket.customer.company}</span>
) : (
<span className="text-warn-400">nedohledáno</span>
)}
<span className="block text-xs text-white/30">
{ticket.customer.contact}
</span>
</td>
<td className="px-5 py-4">
<TicketChannelBadge channel={ticket.channel} />
</td>
<td className="px-5 py-4">
<TicketStatusBadge status={ticket.status} />
</td>
<td className="px-5 py-4">
<TicketPriorityBadge priority={ticket.priority} />
</td>
<td className="px-5 py-4">
{ticket.assignee ? (
<span
className={cn(
'text-white/55',
ticket.assignee.id === meId && 'text-brand-200',
)}
>
{ticket.assignee.name}
</span>
) : (
<span className="text-white/30">ve frontě</span>
)}
</td>
<td className="px-5 py-4 text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</td>
<td className="px-5 py-4">
<Link
to={`/dashboard/tickety/${ticket.id}`}
aria-label={`Detail ticketu ${ticket.id}`}
className="grid size-8 place-items-center rounded-lg text-white/25 transition-colors hover:bg-white/5 hover:text-white group-hover:text-white/60"
>
<ChevronRight className="size-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</DataState>
</div>
</div>
<aside>
<DataState
loading={workload.loading}
error={workload.error}
onRetry={workload.reload}
>
{workload.data && (
<TicketWorkload
workload={workload.data}
active={assignee}
onSelect={setAssignee}
/>
)}
</DataState>
</aside>
</div>
</div>
);
}
function Chip({
active,
onClick,
disabled,
title,
children,
}: {
active: boolean;
onClick: () => void;
disabled?: boolean;
title?: string;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-pressed={active}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
disabled && 'cursor-not-allowed opacity-40 hover:border-ink-600/70 hover:text-white/50',
)}
>
{children}
</button>
);
}
+81 -2
View File
@@ -5,20 +5,93 @@
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
/** Odkud pozadavek prisel. */
export type TicketChannel = 'whatsapp' | 'email' | 'voice' | 'form' | 'portal';
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
export interface Person {
id: string;
name: string;
email: string;
role: string;
capacity: number;
}
export interface TicketCustomer {
/** null = zakaznika se nepodarilo dohledat v CRM. */
id: string | null;
company: string;
contact: string;
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
reply: string;
}
export interface Ticket {
id: string;
subject: string;
requester: string;
channel: TicketChannel;
customer: TicketCustomer;
status: TicketStatus;
priority: TicketPriority;
assignee: string | null;
/** Kdo ma ticket u sebe. null = ceka ve fronte. */
assignee: { id: string; name: string } | null;
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
createdAt: string;
updatedAt: string;
}
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
/** Radek logu ticketu. Strom se sklada pres `parentId`. */
export interface TicketTraceEntry {
id: string;
parentId: string | null;
kind: TraceKind;
connectorId: string | null;
operationId: string | null;
label: string;
status: TraceStatus;
/** Co sluzba vratila. */
response: string | null;
durationMs: number | null;
at: string;
}
export interface TicketDetail extends Ticket {
trace: TicketTraceEntry[];
}
/** Odpoved seznamu ticketu - `meId` rika, ktery resitel je prihlaseny uzivatel. */
export interface TicketListResponse {
items: Ticket[];
meId: string | null;
}
export interface PeopleResponse {
items: Person[];
meId: string | null;
}
export interface WorkloadRow {
person: Person;
open: number;
total: number;
critical: number;
oldestOpenAt: string | null;
overloaded: boolean;
}
/** Prehled nad firmou - kdo co ma u sebe. */
export interface Workload {
rows: WorkloadRow[];
unassigned: number;
openTotal: number;
}
export interface Incident {
id: string;
title: string;
@@ -52,6 +125,7 @@ export interface Automation {
export type ConnectorCategory =
| 'spoustece'
| 'servicedesk'
| 'crm'
| 'ekonomika'
| 'logistika'
@@ -72,6 +146,11 @@ export interface ConnectorOperation {
* (webhook, formular). false/chybi = data urcuje sluzba.
*/
customPayload?: boolean;
/**
* Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni,
* server je pri ukladani stromu vzdy dosadi z katalogu.
*/
providedFields?: TriggerField[];
}
export interface Connector {
+1
View File
@@ -6,6 +6,7 @@
export type DashboardEventType =
| 'ticket.created'
| 'ticket.updated'
| 'ticket.assigned'
| 'ticket.resolved'
| 'incident.started'
| 'incident.updated'