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:
co-authored by
Claude Opus 5
parent
ef3080d3f6
commit
202d525905
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user