Runtime vykonava strom, prokliky z widgetu, oprava ukladani rozlozeni
Runtime: `src/runtime/executor.ts` jde krok po kroku, u podminky se vetvi, do poli dosadi parametry, akci pusti pres runScript a vystupy pripise do kontextu pro dalsi krok. Cely prubeh jde do logu ticketu vcetne toho, co sluzba vratila. Pouzivaji ho obe cesty: akce na ticketu i webhook. Opraveno: rozlozeni dashboardu s vlastnim widgetem se NEDALO ULOZIT. `validateLayout` znala jen vestaveny katalog, takze kazdy pokus skoncil hlaskou "widget v katalogu neexistuje" - presne to, co hlasil uzivatel. Katalog je ted jedna funkce a pouziva ji nabidka i kontrola. Zaroven je za konkretni firmu, driv slo polozit dlazdici jedne firmy na dashboard druhe. Prokliky: z widgetu lidi na cloveka, ze seskupeni na vyfiltrovany seznam ticketu. Odkazy sklada server, protoze on jediny zna filtr widgetu. Seznam ticketu cte filtr z adresy a umi filtrovat na typ, tag a skupinu. Tabulky: spolecna `TicketTable` pro seznam i detail osoby. Na mobilu se neposouva do strany, uzka obrazovka dostane karty. Detail osoby ma velkou tabulku se zalozkami "ma u sebe" a "vyresil" a prepinacem pohledu. Odebrano: simulace vcetne tlacitka, dialogu i endpointu. Trojice pohledu nad tickety - vyber firmy je select, "moje" je prepinac, driv to delalo totez dvakrat. Pridan zmereny rozbor kapacity pro 200 firem (19-kapacita-200-firem.md): soucasny stav to nezvladne, protoze data jsou v pameti a vypis je linearni. Zmereno na 5 000 ticketech, vcetne toho, co s tim a kolik serveru to chce. Overeno 7 kontrolami proti bezicimu serveru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
29de584df8
commit
5d186dcd2e
@@ -2,7 +2,6 @@ import {
|
||||
AlarmClock,
|
||||
Blocks,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
LifeBuoy,
|
||||
@@ -23,7 +22,6 @@ import { useAuth, type AuthUser } from '@/auth/AuthContext';
|
||||
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
|
||||
import { EventToasts } from '@/components/dashboard/EventToasts';
|
||||
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
|
||||
import { SimulationModal } from '@/components/dashboard/SimulationModal';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
@@ -79,7 +77,6 @@ function DashboardShell() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [simulationOpen, setSimulationOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
@@ -135,16 +132,6 @@ function DashboardShell() {
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSimulationOpen(true)}
|
||||
className="mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-ink-600/70 px-3.5 py-2.5 text-sm font-medium text-white/55 transition-colors hover:border-accent-400/60 hover:text-white"
|
||||
>
|
||||
<FlaskConical className="size-4 shrink-0" />
|
||||
Simulace
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-ink-600/60 p-3">
|
||||
@@ -216,7 +203,6 @@ function DashboardShell() {
|
||||
</div>
|
||||
|
||||
<EventToasts />
|
||||
<SimulationModal open={simulationOpen} onClose={() => setSimulationOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Action =
|
||||
| 'ticket.created'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.resolved'
|
||||
| 'automation.run';
|
||||
|
||||
interface Result {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Nízká' },
|
||||
{ value: 'normal', label: 'Běžná' },
|
||||
{ value: 'high', label: 'Vysoká' },
|
||||
{ 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: 'facebook', label: 'Facebook Messenger' },
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ 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ý' },
|
||||
{ value: 'sev1', label: 'SEV1 - kritický' },
|
||||
] as const;
|
||||
|
||||
const inputClass =
|
||||
'w-full 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';
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho,
|
||||
* aby se muselo cekat na skutecny provoz.
|
||||
*
|
||||
* Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru,
|
||||
* takze se projevi i v seznamech a v souhrnu.
|
||||
*/
|
||||
export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [running, setRunning] = useState<Action | null>(null);
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
|
||||
const [subject, setSubject] = 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('');
|
||||
const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2');
|
||||
|
||||
async function run(action: Action, body: Record<string, unknown> = {}) {
|
||||
setRunning(action);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch<unknown>('/api/simulate', {
|
||||
method: 'POST',
|
||||
body: { action, ...body },
|
||||
});
|
||||
setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.';
|
||||
console.error('[simulace] selhalo:', err);
|
||||
setResult({ ok: false, message });
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
}
|
||||
|
||||
function submitTicket(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void run('ticket.created', {
|
||||
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
|
||||
...(subject.trim() ? { subject: subject.trim() } : {}),
|
||||
...(contact.trim() ? { contact: contact.trim() } : {}),
|
||||
channel,
|
||||
priority,
|
||||
knownCustomer,
|
||||
});
|
||||
setSubject('');
|
||||
}
|
||||
|
||||
function submitIncident(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void run('incident.started', {
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(service.trim() ? { service: service.trim() } : {}),
|
||||
severity,
|
||||
});
|
||||
setTitle('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Simulace provozu"
|
||||
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 z kanálu">
|
||||
<form onSubmit={submitTicket} className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<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, 'min-w-0 flex-1')}
|
||||
>
|
||||
{priorities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</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'}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={AlarmClock} title="Nový incident">
|
||||
<form onSubmit={submitIncident} className="space-y-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Popis (nepovinné, jinak se doplní ukázkový)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
value={service}
|
||||
onChange={(event) => setService(event.target.value)}
|
||||
placeholder="Služba"
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(event) =>
|
||||
setSeverity(event.target.value as (typeof severities)[number]['value'])
|
||||
}
|
||||
aria-label="Závažnost"
|
||||
className={cn(inputClass, 'w-44')}
|
||||
>
|
||||
{severities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={running !== null}>
|
||||
<Play className="size-4" />
|
||||
{running === 'incident.started' ? 'Zakládám...' : 'Vyvolat incident'}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Workflow} title="Rychlé akce">
|
||||
<p className="mb-3 text-sm text-white/50">
|
||||
Působí na první vhodný záznam, nemusíte nic vybírat.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<QuickButton
|
||||
onClick={() => void run('ticket.resolved')}
|
||||
disabled={running !== null}
|
||||
busy={running === 'ticket.resolved'}
|
||||
>
|
||||
Vyřešit ticket
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('incident.resolved')}
|
||||
disabled={running !== null}
|
||||
busy={running === 'incident.resolved'}
|
||||
>
|
||||
Vyřešit incident
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('automation.run', { ok: true })}
|
||||
disabled={running !== null}
|
||||
busy={running === 'automation.run'}
|
||||
>
|
||||
Spustit automatizaci
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('automation.run', { ok: false })}
|
||||
disabled={running !== null}
|
||||
busy={running === 'automation.run'}
|
||||
>
|
||||
Neúspěšný běh
|
||||
</QuickButton>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-xl border px-4 py-3 text-sm',
|
||||
result.ok
|
||||
? 'border-ok-400/30 bg-ok-500/10 text-ok-400'
|
||||
: 'border-danger-400/30 bg-danger-500/10 text-danger-400',
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
)}
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
|
||||
Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API,
|
||||
takže se restartem serveru vrátí do výchozího stavu.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof LifeBuoy;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-ink-600/60 bg-ink-850/40 p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Icon className="size-4 text-brand-300" />
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickButton({
|
||||
onClick,
|
||||
disabled,
|
||||
busy,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-ink-600/70 px-3.5 py-2 text-sm text-white/70 transition-colors hover:border-brand-400/60 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Spouštím...' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Clock, RotateCcw } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TicketChannelBadge,
|
||||
TicketPriorityBadge,
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import type { Ticket } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Tabulka ticketu.
|
||||
*
|
||||
* Jedna komponenta pro vsechna mista, kde se tickety vypisuji - seznam,
|
||||
* detail osoby, cokoliv dalsiho. Kdyby si to kazda stranka kreslila po svem,
|
||||
* jedna by casem ukazovala jine sloupce nez druha.
|
||||
*
|
||||
* **Na mobilu se nescroluje do strany.** Uzka obrazovka dostane misto tabulky
|
||||
* karty se stejnymi udaji: vodorovne posouvani v tabulce o osmi sloupcich je
|
||||
* na telefonu nepouzitelne a schovavat sloupce znamena, ze udaj proste zmizi.
|
||||
*/
|
||||
export function TicketTable({
|
||||
tickets,
|
||||
meId,
|
||||
/** Sloupce, ktere nemaji smysl v danem kontextu. */
|
||||
hide,
|
||||
empty = 'Žádné tickety.',
|
||||
}: {
|
||||
tickets: Ticket[];
|
||||
meId?: string | null;
|
||||
hide?: Array<'assignee' | 'customer' | 'channel'>;
|
||||
empty?: string;
|
||||
}) {
|
||||
if (tickets.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">
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const skip = new Set(hide ?? []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tabulka od tabletu vys. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
|
||||
<tr>
|
||||
<th className="px-3 py-3 font-medium">Ticket</th>
|
||||
{!skip.has('customer') && <th className="px-3 py-3 font-medium">Zákazník</th>}
|
||||
{!skip.has('channel') && <th className="px-3 py-3 font-medium">Kanál</th>}
|
||||
<th className="px-3 py-3 font-medium">Stav</th>
|
||||
<th className="px-3 py-3 font-medium">Priorita</th>
|
||||
{!skip.has('assignee') && <th className="px-3 py-3 font-medium">Řeší</th>}
|
||||
<th className="px-3 py-3 text-right font-medium">Aktualizace</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-600/40">
|
||||
{tickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-3">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block text-white/85 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-xs text-white/35">
|
||||
{ticket.id}
|
||||
{ticket.externalId && <span>externí {ticket.externalId}</span>}
|
||||
{ticket.reopenCount > 0 && (
|
||||
<span className="flex items-center gap-1 font-sans text-warn-400">
|
||||
<RotateCcw className="size-3" />
|
||||
{ticket.reopenCount}x vráceno
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{!skip.has('customer') && (
|
||||
<td className="px-3 py-3">
|
||||
{ticket.customer.id ? (
|
||||
<span className="text-white/55">{ticket.customer.company}</span>
|
||||
) : (
|
||||
<span className="text-warn-400">nedohledáno</span>
|
||||
)}
|
||||
{ticket.customer.contact && (
|
||||
<span className="block text-xs text-white/30">{ticket.customer.contact}</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{!skip.has('channel') && (
|
||||
<td className="px-3 py-3">
|
||||
<TicketChannelBadge channel={ticket.channel} />
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-3 py-3">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
</td>
|
||||
|
||||
{!skip.has('assignee') && (
|
||||
<td className="px-3 py-3">
|
||||
{ticket.assignee ? (
|
||||
<Link
|
||||
to={`/dashboard/lide/${ticket.assignee.id}`}
|
||||
className={cn(
|
||||
'transition-colors hover:text-brand-200',
|
||||
ticket.assignee.id === meId ? 'text-brand-200' : 'text-white/55',
|
||||
)}
|
||||
>
|
||||
{ticket.assignee.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-white/30">ve frontě</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-3 py-3 text-right whitespace-nowrap text-white/45">
|
||||
<span title={formatDateTime(ticket.updatedAt)}>
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Na uzke obrazovce karty misto posouvani do strany. */}
|
||||
<ul className="divide-y divide-ink-600/40 md:hidden">
|
||||
{tickets.map((ticket) => (
|
||||
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<Link to={`/dashboard/tickety/${ticket.id}`} className="block">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm text-white/85">{ticket.subject}</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{!skip.has('channel') && <TicketChannelBadge channel={ticket.channel} />}
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
{ticket.externalId && <Badge tone="neutral">externí {ticket.externalId}</Badge>}
|
||||
{ticket.reopenCount > 0 && <Badge tone="warn">{ticket.reopenCount}x vráceno</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
{!skip.has('customer') && (
|
||||
<span className="truncate">
|
||||
{ticket.customer.id ? (
|
||||
ticket.customer.company
|
||||
) : (
|
||||
<span className="text-warn-400">zákazník nedohledán</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!skip.has('assignee') && (
|
||||
<span className={cn(ticket.assignee?.id === meId && 'text-brand-200')}>
|
||||
{ticket.assignee?.name ?? 've frontě'}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, Inbox, Users } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronRight, Inbox, Users } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatRelative } from '@/lib/format';
|
||||
import type { Workload } from '@/types/dashboard';
|
||||
@@ -39,19 +40,35 @@ export function TicketWorkload({
|
||||
const width = busiest === 0 ? 0 : Math.round((row.open / busiest) * 100);
|
||||
|
||||
return (
|
||||
<li key={row.person.id}>
|
||||
<li
|
||||
key={row.person.id}
|
||||
className={cn(
|
||||
'relative rounded-xl border transition-colors',
|
||||
isActive
|
||||
? 'border-brand-400/50 bg-brand-500/10'
|
||||
: 'border-transparent hover:border-ink-600/70 hover:bg-white/[0.03]',
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
Radek filtruje seznam, sipka vpravo vede na cloveka. Odkaz nesmi
|
||||
byt uvnitr tlacitka - dva ovladace v sobe jsou neplatne HTML
|
||||
a klavesnici se v tom neda pohybovat.
|
||||
*/}
|
||||
<Link
|
||||
to={`/dashboard/lide/${row.person.id}`}
|
||||
aria-label={`Detail ${row.person.name}`}
|
||||
className="absolute top-2 right-2 z-10 grid size-7 place-items-center rounded-lg text-white/25 transition-colors hover:bg-white/8 hover:text-brand-200"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
<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]',
|
||||
)}
|
||||
className="w-full rounded-xl px-3.5 py-2.5 text-left"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex items-baseline justify-between gap-3 pr-8">
|
||||
<span className="truncate text-sm font-medium text-white/85">
|
||||
{row.person.name}
|
||||
</span>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function CustomWidgetCard({
|
||||
}
|
||||
|
||||
if (value.kind === 'number') {
|
||||
return <Number definition={definition} value={value.value} />;
|
||||
return <Number definition={definition} value={value.value} href={value.href} />;
|
||||
}
|
||||
|
||||
if (value.kind === 'external') {
|
||||
@@ -91,7 +91,7 @@ export function CustomWidgetCard({
|
||||
|
||||
if (value.kind === 'series') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Shell definition={definition} href={value.href}>
|
||||
<Sparkline points={value.points} />
|
||||
</Shell>
|
||||
);
|
||||
@@ -99,7 +99,7 @@ export function CustomWidgetCard({
|
||||
|
||||
if (value.kind === 'tickets') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Shell definition={definition} href={value.href}>
|
||||
{value.items.length === 0 ? (
|
||||
<p className="text-sm text-white/40">Žádné tickety.</p>
|
||||
) : (
|
||||
@@ -127,13 +127,15 @@ export function CustomWidgetCard({
|
||||
|
||||
if (value.kind === 'workload') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Shell definition={definition} href={value.href}>
|
||||
<Bars
|
||||
rows={value.rows.map((row) => ({
|
||||
key: row.name,
|
||||
key: row.personId,
|
||||
label: row.name,
|
||||
value: row.open,
|
||||
warn: row.overCapacity,
|
||||
// Z pruhu s jmenem se chce clovek dostat na toho cloveka.
|
||||
href: row.href,
|
||||
}))}
|
||||
/>
|
||||
</Shell>
|
||||
@@ -232,32 +234,56 @@ function AgentTable({ rows }: { rows: AgentStatsRow[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo. */
|
||||
/**
|
||||
* Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo.
|
||||
*
|
||||
* Radek s `href` je odkaz na to, co je za nim: u lidi na cloveka, u seskupeni
|
||||
* na uz vyfiltrovany seznam ticketu.
|
||||
*/
|
||||
function Bars({
|
||||
rows,
|
||||
}: {
|
||||
rows: Array<{ key: string; label: string; value: number; warn?: boolean }>;
|
||||
rows: Array<{ key: string; label: string; value: number; warn?: boolean; href?: string }>;
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value));
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{rows.slice(0, 12).map((row) => (
|
||||
<li key={row.key}>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-white/70">{row.label}</span>
|
||||
<span className={cn('font-semibold', row.warn ? 'text-warn-400' : 'text-white')}>
|
||||
{formatNumber(row.value)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className={cn('h-full rounded-full', row.warn ? 'bg-warn-400/70' : 'bg-brand-400/70')}
|
||||
style={{ width: `${Math.round((row.value / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{rows.slice(0, 12).map((row) => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-white/70 group-hover:text-brand-200">
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={cn('font-semibold', row.warn ? 'text-warn-400' : 'text-white')}>
|
||||
{formatNumber(row.value)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
row.warn ? 'bg-warn-400/70' : 'bg-brand-400/70',
|
||||
)}
|
||||
style={{ width: `${Math.round((row.value / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={row.key}>
|
||||
{row.href ? (
|
||||
<Link to={row.href} className="group block">
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -268,21 +294,38 @@ function Number({
|
||||
value,
|
||||
at,
|
||||
stale,
|
||||
href,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
value: number | null;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
href?: string;
|
||||
}) {
|
||||
const target = definition.target;
|
||||
const share = target && target > 0 && value !== null ? Math.min(value / target, 1) : null;
|
||||
|
||||
const number = (
|
||||
<span
|
||||
className={cn(
|
||||
'text-3xl font-bold tabular-nums',
|
||||
href ? 'text-white transition-colors group-hover:text-brand-200' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{value === null ? '-' : formatNumber(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell definition={definition} at={at} stale={stale}>
|
||||
<Shell definition={definition} at={at} stale={stale} href={href}>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">
|
||||
{value === null ? '-' : formatNumber(value)}
|
||||
</span>
|
||||
{href ? (
|
||||
<Link to={href} className="group">
|
||||
{number}
|
||||
</Link>
|
||||
) : (
|
||||
number
|
||||
)}
|
||||
{target !== undefined && target > 0 && (
|
||||
<span className="mb-1 flex items-center gap-1 text-xs text-white/40">
|
||||
{value !== null && value >= target ? (
|
||||
@@ -345,18 +388,30 @@ function Shell({
|
||||
definition,
|
||||
at,
|
||||
stale,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
/** Kam vede nadpis. Bez nej je to jen text. */
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass h-full rounded-card p-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate font-semibold text-white">{definition.name}</h2>
|
||||
{href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="block truncate font-semibold text-white transition-colors hover:text-brand-200"
|
||||
>
|
||||
{definition.name}
|
||||
</Link>
|
||||
) : (
|
||||
<h2 className="truncate font-semibold text-white">{definition.name}</h2>
|
||||
)}
|
||||
{definition.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-white/35">{definition.description}</p>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ArrowLeft, Clock, Mail, RotateCcw, Users } from 'lucide-react';
|
||||
import { ArrowLeft, Mail, Users } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { TicketTable } from '@/components/dashboard/TicketTable';
|
||||
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
|
||||
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
|
||||
@@ -26,6 +29,10 @@ interface PersonDetailResponse {
|
||||
export default function PersonDetail() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
/** Co ukazat: co ma u sebe, nebo co vyresil. */
|
||||
const [tab, setTab] = useState<'open' | 'resolved'>('open');
|
||||
const [view, setView] = useState<ListView>('table');
|
||||
|
||||
const detail = useApiQuery<PersonDetailResponse>(`/api/dashboard/people/${id}`, {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
@@ -105,19 +112,34 @@ export default function PersonDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<TicketColumn
|
||||
title="Má u sebe"
|
||||
empty="Nic nemá u sebe."
|
||||
tickets={detail.data.open}
|
||||
showAge
|
||||
/>
|
||||
<TicketColumn
|
||||
title="Naposledy vyřešil"
|
||||
empty="Zatím nic nevyřešil."
|
||||
tickets={detail.data.resolved}
|
||||
/>
|
||||
</div>
|
||||
<section className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TabChip active={tab === 'open'} onClick={() => setTab('open')}>
|
||||
Má u sebe ({detail.data.open.length})
|
||||
</TabChip>
|
||||
<TabChip active={tab === 'resolved'} onClick={() => setTab('resolved')}>
|
||||
Vyřešil ({detail.data.resolved.length})
|
||||
</TabChip>
|
||||
</div>
|
||||
<ViewSwitch view={view} onChange={setView} />
|
||||
</div>
|
||||
|
||||
{view === 'table' ? (
|
||||
<TicketTable
|
||||
tickets={tab === 'open' ? detail.data.open : detail.data.resolved}
|
||||
// Sloupec Resi je u detailu jednoho cloveka k nicemu.
|
||||
hide={['assignee']}
|
||||
empty={tab === 'open' ? 'Nic nemá u sebe.' : 'Zatím nic nevyřešil.'}
|
||||
/>
|
||||
) : (
|
||||
<TicketCards
|
||||
tickets={tab === 'open' ? detail.data.open : detail.data.resolved}
|
||||
showAge={tab === 'open'}
|
||||
empty={tab === 'open' ? 'Nic nemá u sebe.' : 'Zatím nic nevyřešil.'}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
@@ -125,71 +147,82 @@ export default function PersonDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
function TicketColumn({
|
||||
title,
|
||||
empty,
|
||||
tickets,
|
||||
showAge,
|
||||
/** Zalozka nad tabulkou. Maly prvek, patri k teto strance. */
|
||||
function TabChip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
empty: string;
|
||||
tickets: Ticket[];
|
||||
showAge?: boolean;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass rounded-card p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">{title}</h2>
|
||||
<span className="text-xs text-white/35">{tickets.length}</span>
|
||||
</div>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-sm text-white/40">{empty}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{tickets.map((ticket) => (
|
||||
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/40">
|
||||
{ticket.externalId && (
|
||||
<span className="font-mono">externí {ticket.externalId}</span>
|
||||
)}
|
||||
{showAge ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
leží {formatRelative(ticket.createdAt)}
|
||||
</span>
|
||||
) : (
|
||||
ticket.resolvedAt && <span>vyřešeno {formatRelative(ticket.resolvedAt)}</span>
|
||||
)}
|
||||
{ticket.reopenCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-warn-400">
|
||||
<RotateCcw className="size-3" />
|
||||
vráceno {ticket.reopenCount}x
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
{(ticket.priority === 'critical' || ticket.priority === 'high') && (
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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',
|
||||
)}
|
||||
</section>
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dlazdicovy pohled na tytéz tickety. */
|
||||
function TicketCards({
|
||||
tickets,
|
||||
showAge,
|
||||
empty,
|
||||
}: {
|
||||
tickets: Ticket[];
|
||||
showAge: boolean;
|
||||
empty: string;
|
||||
}) {
|
||||
if (tickets.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">
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="group rounded-card border border-ink-600/50 bg-ink-900/40 p-4 transition-colors hover:border-brand-400/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<p className="mt-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
|
||||
{ticket.subject}
|
||||
</p>
|
||||
<p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/40">
|
||||
{ticket.externalId && <span className="font-mono">externí {ticket.externalId}</span>}
|
||||
<span>
|
||||
{showAge
|
||||
? `leží ${formatRelative(ticket.createdAt)}`
|
||||
: ticket.resolvedAt
|
||||
? `vyřešeno ${formatRelative(ticket.resolvedAt)}`
|
||||
: ''}
|
||||
</span>
|
||||
{ticket.reopenCount > 0 && (
|
||||
<span className="text-warn-400">vráceno {ticket.reopenCount}x</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+104
-119
@@ -1,7 +1,7 @@
|
||||
import { ChevronRight, Search } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import {
|
||||
TicketChannelBadge,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
|
||||
import { TicketTable } from '@/components/dashboard/TicketTable';
|
||||
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -34,12 +35,6 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
|
||||
{ value: 'resolved', label: 'Vyřešené' },
|
||||
];
|
||||
|
||||
const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [
|
||||
{ value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' },
|
||||
{ value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' },
|
||||
{ value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' },
|
||||
];
|
||||
|
||||
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'facebook', label: 'Facebook' },
|
||||
@@ -56,15 +51,36 @@ export default function Tickets() {
|
||||
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
const [scope, setScope] = useState<TicketScope | null>(null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
/*
|
||||
* Pocatecni filtr z adresy. Diky tomu vede proklik z widgetu na uz
|
||||
* vyfiltrovany seznam a odkaz jde poslat kolegovi.
|
||||
*/
|
||||
const [params] = useSearchParams();
|
||||
const initial = useRef({
|
||||
tenantId: params.get('tenantId'),
|
||||
assignee: params.get('assignee'),
|
||||
status: params.get('status') as TicketStatus | null,
|
||||
channel: params.get('channel') as TicketChannel | null,
|
||||
typeId: params.get('typeId'),
|
||||
tag: params.get('tag'),
|
||||
groupId: params.get('groupId'),
|
||||
}).current;
|
||||
|
||||
const [scope, setScope] = useState<TicketScope | null>(initial.tenantId ? 'tenant' : null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(initial.tenantId);
|
||||
/** null = vsichni, '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 [assignee, setAssignee] = useState<string | null>(initial.assignee);
|
||||
const [status, setStatus] = useState<TicketStatus | null>(initial.status);
|
||||
const [channel, setChannel] = useState<TicketChannel | null>(initial.channel);
|
||||
/** Filtry, ktere se daji nastavit jen odkazem. V UI k nim tlacitko neni. */
|
||||
const [typeId, setTypeId] = useState<string | null>(initial.typeId);
|
||||
const [tag, setTag] = useState<string | null>(initial.tag);
|
||||
const [groupId, setGroupId] = useState<string | null>(initial.groupId);
|
||||
const [query, setQuery] = useState('');
|
||||
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
|
||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||
/** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */
|
||||
const [mine, setMine] = useState(false);
|
||||
|
||||
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
|
||||
const path = useMemo(() => {
|
||||
@@ -72,6 +88,9 @@ export default function Tickets() {
|
||||
if (scope) params.set('scope', scope);
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
if (assignee) params.set('assignee', assignee);
|
||||
if (typeId) params.set('typeId', typeId);
|
||||
if (tag) params.set('tag', tag);
|
||||
if (groupId) params.set('groupId', groupId);
|
||||
if (status) params.set('status', status);
|
||||
if (channel) params.set('channel', channel);
|
||||
const search = params.toString();
|
||||
@@ -140,29 +159,34 @@ export default function Tickets() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */}
|
||||
{/*
|
||||
Dva ovladace, ne tri. Firma se vybira selectem, "moje" prepinacem.
|
||||
Puvodni trojice pohledu delala totez podruhe: pohled Firma znamenal
|
||||
tentyz vyber jako select vedle nej a pohled Moje totez co prepinac.
|
||||
*/}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{scopeOptions
|
||||
.filter((option) => allowedScopes.includes(option.value))
|
||||
.map((option) => (
|
||||
<Chip
|
||||
key={option.value}
|
||||
active={activeScope === option.value}
|
||||
onClick={() => setScope(option.value)}
|
||||
title={option.hint}
|
||||
>
|
||||
{option.label}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
|
||||
{tenants.length > 1 && activeScope !== 'all' && (
|
||||
{tenants.length > 1 && (
|
||||
<select
|
||||
value={tenantId ?? data?.tenantId ?? ''}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
value={activeScope === 'all' ? '' : (tenantId ?? data?.tenantId ?? '')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (value === '') {
|
||||
// Prazdna volba = napric firmami. Smi ji jen ten, kdo na ni ma pravo.
|
||||
setScope('all');
|
||||
setTenantId(null);
|
||||
} else {
|
||||
setScope(mine ? 'mine' : 'tenant');
|
||||
setTenantId(value);
|
||||
}
|
||||
}}
|
||||
aria-label="Firma"
|
||||
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{allowedScopes.includes('all') && (
|
||||
<option value="" className="bg-ink-850">
|
||||
Všechny firmy
|
||||
</option>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
|
||||
{tenant.name}
|
||||
@@ -170,18 +194,56 @@ export default function Tickets() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
|
||||
Kdokoliv
|
||||
</Chip>
|
||||
{allowedScopes.includes('mine') && (
|
||||
<Chip
|
||||
active={mine}
|
||||
onClick={() => {
|
||||
const next = !mine;
|
||||
setMine(next);
|
||||
setScope(next ? 'mine' : activeScope === 'all' ? 'all' : 'tenant');
|
||||
if (next) setAssignee(null);
|
||||
}}
|
||||
title="Jen tickety, které mám u sebe."
|
||||
>
|
||||
Moje tickety
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
active={assignee === 'unassigned'}
|
||||
onClick={() => setAssignee(assignee === 'unassigned' ? null : 'unassigned')}
|
||||
onClick={() => {
|
||||
setAssignee(assignee === 'unassigned' ? null : 'unassigned');
|
||||
// Fronta a moje se vylucuji, ticket ve fronte nema resitele.
|
||||
if (assignee !== 'unassigned' && mine) {
|
||||
setMine(false);
|
||||
setScope('tenant');
|
||||
}
|
||||
}}
|
||||
title="Tickety, které nemá nikdo u sebe."
|
||||
>
|
||||
Ve frontě
|
||||
</Chip>
|
||||
|
||||
{/*
|
||||
Filtry z odkazu. V UI k nim tlacitko neni, ale musi jit zrusit -
|
||||
jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane.
|
||||
*/}
|
||||
{typeId && (
|
||||
<Chip active onClick={() => setTypeId(null)} title="Zrušit filtr na typ">
|
||||
typ: {typeId === 'none' ? 'bez typu' : typeId} x
|
||||
</Chip>
|
||||
)}
|
||||
{tag && (
|
||||
<Chip active onClick={() => setTag(null)} title="Zrušit filtr na tag">
|
||||
tag: {tag === 'none' ? 'bez tagu' : tag} x
|
||||
</Chip>
|
||||
)}
|
||||
{groupId && (
|
||||
<Chip active onClick={() => setGroupId(null)} title="Zrušit filtr na skupinu">
|
||||
skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
@@ -283,87 +345,10 @@ export default function Tickets() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
/* Tatáz tabulka jako na detailu osoby, vcetne chovani na mobilu. */
|
||||
<div className="p-2 sm:p-3">
|
||||
<TicketTable tickets={items} meId={meId} />
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
@@ -494,14 +494,29 @@ export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
|
||||
|
||||
/** Hodnota vlastniho widgetu ze serveru. Podle `kind` se pozna, co s tim. */
|
||||
export type WidgetValue =
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> }
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }> }
|
||||
/** `href` je odkaz na to, co je za cislem. Sklada ho server, ne klient. */
|
||||
| { kind: 'number'; value: number; href?: string }
|
||||
| {
|
||||
kind: 'groups';
|
||||
rows: Array<{ key: string; label: string; value: number; href?: string }>;
|
||||
}
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
|
||||
| {
|
||||
kind: 'tickets';
|
||||
items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>;
|
||||
href?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'workload';
|
||||
rows: Array<{
|
||||
personId: string;
|
||||
name: string;
|
||||
open: number;
|
||||
overCapacity: boolean;
|
||||
href: string;
|
||||
}>;
|
||||
href?: string;
|
||||
}
|
||||
| { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }
|
||||
| { kind: 'agents'; rows: AgentStatsRow[] }
|
||||
/**
|
||||
* Vysledek z konektoru. `stale` znamena, ze je to z mezipameti serveru -
|
||||
|
||||
Reference in New Issue
Block a user