Stav ticketu je volny retezec, ciselnik pryc

Ciselnik new/open/waiting/resolved je pryc. Tickety chodi z cizich aplikaci,
ktere maji svoje stavy - voicebot posila ringing a completed. Nutit je do nasi
ctverice znamenalo, ze u ticketu svitilo "Novy", i kdyz byl podle odesilatele
davno hotovy.

Misto nej priznak `closed`: fronta, vytizeni i statistiky potrebuji vedet, co
uz nikdo neresi, a z volneho retezce to poznat nejde. Nastavuje se sam podle
`TicketType.closedStatuses`, a kdyz je typ nema, podle bezneho pojmenovani
(vyreseno, hotovo, completed, closed).

`stage` zruseno. Byla to obchazka, jak dostat cizi stavy do ticketu, aniz by
se sahlo na ciselnik. Kdyz je stav volny, druhe pole na tutéz vec jen matlo.

Vyber stavu v detailu nabizi stavy typu, doporucene a ten, ktery ticket ma
prave ted, aby hodnota z cizi aplikace ze seznamu nezmizela. Filtr v seznamu
nabizi stavy, ktere v datech opravdu jsou.

Overeno 11 kontrolami: ticket z voicebota ma stav ringing, pak in-progress
a completed, completed se pozna jako hotovo a zmizi z fronty, filtr i widget
ukazuji tvoje stavy a rucne jde nastavit i "ceka na zpetne volani".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 17:36:41 +02:00
co-authored by Claude Opus 5
parent ef3080d3f6
commit 202d525905
16 changed files with 290 additions and 220 deletions
+33 -24
View File
@@ -28,11 +28,27 @@ import type {
* text i ikona (pristupnost, viz docs/05-design-system.md).
*/
const ticketStatusMap: Record<TicketStatus, { label: string; tone: BadgeTone }> = {
new: { label: 'Nový', tone: 'brand' },
open: { label: 'V řešení', tone: 'warn' },
waiting: { label: 'Čeká na klienta', tone: 'neutral' },
resolved: { label: 'Vyřešeno', tone: 'ok' },
/**
* Barva podle stavu.
*
* Stav je volny retezec, takze mapa nemuze byt uplna. Zname nazvy dostanou
* svou barvu, cokoliv jineho neutralni - `ringing` z voicebota nema jak vedet,
* jestli je to dobre nebo spatne, a hadat by bylo horsi nez nehadat.
*/
const knownStatusTones: Record<string, BadgeTone> = {
nový: 'brand',
new: 'brand',
'v řešení': 'warn',
open: 'warn',
'čeká na klienta': 'neutral',
waiting: 'neutral',
vyřešeno: 'ok',
hotovo: 'ok',
uzavřeno: 'ok',
resolved: 'ok',
closed: 'ok',
completed: 'ok',
done: 'ok',
};
const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone }> = {
@@ -75,34 +91,27 @@ const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone
* vedle jako mensi znacka - pocitaji se z nej statistiky a fronta, takze
* schovat ho uplne by znamenalo, ze nikdo nepozna, proc je ticket ve fronte.
*/
/**
* Stav ticketu tak, jak ho nekdo nastavil.
*
* Zadny prevod, zadny ciselnik: co je v datech, to se ukaze. `closed` rika,
* jestli uz to nikdo neresi - podle toho se vybere ikona.
*/
export function TicketStatusBadge({
status,
stage,
closed,
}: {
status: TicketStatus;
stage?: string | null;
closed?: boolean;
}) {
const { label, tone } = ticketStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'waiting' ? Clock : CircleDot;
if (stage) {
return (
<span className="inline-flex flex-wrap items-center gap-1.5">
<Badge tone={tone}>
<Icon className="size-3.5" />
{stage}
</Badge>
<span className="text-xs text-white/30" title="Stav v životním cyklu, podle něj se počítají statistiky">
{label}
</span>
</span>
);
}
const key = status.trim().toLowerCase();
const tone = knownStatusTones[key] ?? (closed ? 'ok' : 'neutral');
const Icon = closed ? CheckCircle2 : key.startsWith('čeká') ? Clock : CircleDot;
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
{label}
{status}
</Badge>
);
}
+2 -2
View File
@@ -101,7 +101,7 @@ export function TicketTable({
)}
<td className="px-3 py-3">
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</td>
<td className="px-3 py-3">
<TicketPriorityBadge priority={ticket.priority} />
@@ -143,7 +143,7 @@ export function TicketTable({
<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} stage={ticket.stage} />
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-1 text-sm text-white/85">{ticket.subject}</p>
+1 -1
View File
@@ -202,7 +202,7 @@ function TicketCards({
>
<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} stage={ticket.stage} />
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
+38 -12
View File
@@ -8,7 +8,7 @@ import {
ScrollText,
UserCheck,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
@@ -31,15 +31,15 @@ import type {
ServiceCatalog,
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' },
];
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
*
* Je to nabidka, ne ciselnik - stav je volny retezec a ticket muze mit
* i hodnotu, kterou poslala cizi aplikace.
*/
const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', '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';
@@ -57,6 +57,32 @@ export default function TicketDetail() {
const [actionError, setActionError] = useState<string | null>(null);
const [comment, setComment] = useState('');
/*
* Co nabidnout ve vyberu stavu.
*
* Stav je volny retezec, takze nabidka je jen pohodli. Sklada se ze stavu
* typu ticketu (kdyz si je firma nadefinovala), z doporucenych a **z toho,
* co ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const [types, setTypes] = useState<Array<{ id: string; statuses: string[] }>>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; statuses: string[] }> }>(
'/api/dashboard/settings/ticket-types',
)
.then((data) => setTypes(data.items))
.catch(() => setTypes([]));
}, []);
const statusChoices = useMemo(() => {
const type = types.find((item) => item.id === ticket.data?.typeId);
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
const current = ticket.data?.status;
return [...new Set([...base, ...(current ? [current] : [])])];
}, [types, ticket.data?.typeId, ticket.data?.status]);
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} - portál Automia` });
// Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu.
@@ -117,7 +143,7 @@ export default function TicketDetail() {
<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} stage={ticket.data.stage} />
<TicketStatusBadge status={ticket.data.status} closed={ticket.data.closed} />
<TicketPriorityBadge priority={ticket.data.priority} />
{ticket.data.externalId && (
<Badge tone="neutral">
@@ -334,9 +360,9 @@ export default function TicketDetail() {
}
className={cn(selectClass, 'mt-2 w-full')}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
{statusChoices.map((option) => (
<option key={option} value={option} className="bg-ink-850">
{option}
</option>
))}
</select>
+10 -13
View File
@@ -28,13 +28,6 @@ import type {
/** 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: 'facebook', label: 'Facebook' },
@@ -255,13 +248,17 @@ export default function Tickets() {
</div>
<div className="mt-2 flex flex-wrap gap-2">
{statusFilters.map((item) => (
{/*
Stavy z dat, ne z pevneho seznamu. Ticket z cizi aplikace ma
jeji stavy a pevna nabidka by je nikdy netrefila.
*/}
{(data?.statuses ?? []).map((value) => (
<Chip
key={item.value}
active={status === item.value}
onClick={() => setStatus(status === item.value ? null : item.value)}
key={value}
active={status === value}
onClick={() => setStatus(status === value ? null : value)}
>
{item.label}
{value}
</Chip>
))}
</div>
@@ -301,7 +298,7 @@ export default function Tickets() {
>
<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} stage={ticket.stage} />
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
+14 -6
View File
@@ -3,7 +3,14 @@
* Az bude API stabilni, vygenerovat je sdilene (viz docs/06-dashboard.md).
*/
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
/**
* Stav ticketu. **Volny retezec**, ne ciselnik.
*
* Tickety chodi z cizich aplikaci, ktere maji svoje stavy. Nutit je do nasi
* ctverice znamenalo, ze u ticketu z voicebota svitilo "Novy", i kdyz byl
* podle odesilatele hotovy.
*/
export type TicketStatus = string;
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
/** Odkud pozadavek prisel. */
export type TicketChannel =
@@ -105,13 +112,12 @@ export interface Ticket {
/** Hodnoty vlastnich poli daneho typu. */
fields: Record<string, string | number | boolean | null>;
/**
* Faze ve workflow typu, napr. "ceka na zabaleni".
* Uz to nikdo neresi?
*
* Tri osy: `status` je zivotni cyklus (podle nej se pocitaji statistiky),
* `stage` je postup uvnitr typu a definuje si ho firma, `tags` jsou volne
* stitky. Faze muze byt jen jedna, proto se na ni da spolehnout v podmince.
* Kdyz je stav volny retezec, nejde z nej poznat, jestli je hotovo.
* Fronta i statistiky se proto ptaji na tenhle priznak.
*/
stage: string | null;
closed: boolean;
/** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */
tags: string[];
/** Kdy se ticketu prvne nekdo venoval. */
@@ -153,6 +159,8 @@ export interface TicketDetail extends Ticket {
/** Odpoved seznamu ticketu - `meId` rika, ktery resitel je prihlaseny uzivatel. */
export interface TicketListResponse {
items: Ticket[];
/** Stavy, ktere se ve vysledku vyskytuji. Filtr z nich sklada nabidku. */
statuses: string[];
meId: string | null;
/** Pohled, ktery server skutecne pouzil. */
scope: TicketScope;