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
@@ -2,6 +2,37 @@
|
||||
|
||||
Nejnovejsi nahore.
|
||||
|
||||
## 2026-08-13 - stav ticketu je volny retezec, ciselnik pryc
|
||||
|
||||
### Zmeneno
|
||||
|
||||
- **`Ticket.status` je volny retezec.** Ciselnik `new | open | waiting |
|
||||
resolved` je pryc. Tickety chodi z cizich aplikaci, ktere maji svoje stavy -
|
||||
voicebot posila `ringing` a `completed`, e-shop `pripraveno k expedici`.
|
||||
Nutit je do nasi ctverice znamenalo, ze u ticketu svitilo "Novy", i kdyz byl
|
||||
podle odesilatele davno hotovy.
|
||||
- **Pribyl priznak `Ticket.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 je jen
|
||||
zmateni. Hodnoty z faze patri do stavu.
|
||||
- Vyber stavu v detailu ticketu nabizi stavy typu, doporucene a **ten, ktery
|
||||
ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu zmizela
|
||||
a prvni rucni zmena by ji prepsala.
|
||||
- Filtr v seznamu ticketu nabizi **stavy, ktere v datech opravdu jsou**, ne
|
||||
pevnou ctverici.
|
||||
- Barva u stavu: zname nazvy maji svou, cokoliv jineho neutralni. Hadat, jestli
|
||||
je `ringing` dobre nebo spatne, by bylo horsi nez nehadat.
|
||||
|
||||
### Overeno
|
||||
|
||||
11 kontrol proti bezicimu serveru: ticket z voicebota ma stav `ringing`, po
|
||||
dalsich zpravach `in-progress` a `completed`, `completed` se pozna jako hotovo,
|
||||
hotovy ticket zmizi z fronty, filtr nabizi stavy z dat a funguje na ne, widget
|
||||
je ukazuje misto ciselniku a rucne jde nastavit i `ceka na zpetne volani`.
|
||||
|
||||
## 2026-08-13 - nastaveni prezije nasazeni, ukazkova data uz se nevraci
|
||||
|
||||
Bez databaze lezi data uvnitr containeru, takze **redeploy je smaze** a seed
|
||||
|
||||
@@ -25,10 +25,8 @@ export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
|
||||
export type WidgetGroupBy =
|
||||
| 'assignee'
|
||||
| 'group'
|
||||
/** Zivotni cyklus: novy, v reseni, ceka, vyreseno. */
|
||||
/** Stav ticketu. Volny retezec, u ticketu z cizich aplikaci jejich vlastni. */
|
||||
| 'status'
|
||||
/** Faze z workflow typu, napr. stavy hovoru od voicebota. */
|
||||
| 'stage'
|
||||
| 'type'
|
||||
| 'tag'
|
||||
| 'channel';
|
||||
@@ -120,19 +118,6 @@ export function seedCustomWidgets(): CustomWidget[] {
|
||||
},
|
||||
size: 'half',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 'cw_stage_month',
|
||||
name: 'Tickety podle fáze',
|
||||
description: 'Fáze z workflow typu, například stavy hovorů. Za tento měsíc.',
|
||||
render: 'table',
|
||||
source: {
|
||||
kind: 'ticketCount',
|
||||
filter: { period: 'month' },
|
||||
groupBy: 'stage',
|
||||
},
|
||||
size: 'half',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 'cw_idoklad_invoices',
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ export function getSummary(tenantIds: string[]) {
|
||||
}
|
||||
|
||||
return {
|
||||
openTickets: tickets.filter((t) => t.status !== 'resolved').length,
|
||||
// Stav je volny retezec, o hotovosti rozhoduje priznak.
|
||||
openTickets: tickets.filter((t) => !t.closed).length,
|
||||
activeIncidents: incidents.filter((i) => i.status !== 'resolved').length,
|
||||
activeAutomations: automations.filter((a) => a.enabled).length,
|
||||
runsToday,
|
||||
|
||||
+80
-65
@@ -24,7 +24,46 @@ import { findPerson, type Person } from './people.js';
|
||||
import { defineStore } from './store/index.js';
|
||||
import { withMirror } from './store/mirror.js';
|
||||
|
||||
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
|
||||
/**
|
||||
* Stav ticketu. **Volny retezec**, ne ciselnik.
|
||||
*
|
||||
* Tickety chodi z cizich aplikaci, ktere maji svoje stavy: voicebot posila
|
||||
* `ringing` a `completed`, e-shop `pripraveno k expedici`. Nutit je do nasi
|
||||
* ctverice znamenalo, ze uzivatel videl "Novy" u ticketu, ktery byl podle
|
||||
* jeho systemu davno hotovy.
|
||||
*
|
||||
* Vychozi ctverice zustava jako **doporuceni**, ne omezeni - kdo si nic
|
||||
* nenastavi, dostane rozumne stavy a nemusi vymyslet vlastni.
|
||||
*/
|
||||
export type TicketStatus = string;
|
||||
|
||||
/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni workflow. */
|
||||
export const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'] as const;
|
||||
|
||||
/**
|
||||
* Stavy, ktere znamenaji hotovo.
|
||||
*
|
||||
* Fronta i statistiky potrebuji vedet, co uz nikdo neresi. Kdyz je stav volny
|
||||
* retezec, nejde to poznat z nej samotneho - proto se to drzi jako priznak
|
||||
* `closed` na ticketu a tenhle seznam rika, ze se ma nastavit sam.
|
||||
*
|
||||
* Anglicke varianty jsou tu kvuli cizim aplikacim: voicebot posle `completed`
|
||||
* a nikdo by nemel muset rucne rikat, ze to znamena hotovo.
|
||||
*/
|
||||
const CLOSING = new Set(
|
||||
['vyřešeno', 'hotovo', 'uzavřeno', 'resolved', 'closed', 'done', 'completed'].map((value) =>
|
||||
value.toLowerCase(),
|
||||
),
|
||||
);
|
||||
|
||||
/** Znamena tenhle stav, ze uz to nikdo neresi? */
|
||||
export function isClosingStatus(status: string, closedStatuses?: string[]): boolean {
|
||||
const value = status.trim().toLowerCase();
|
||||
if (closedStatuses && closedStatuses.length > 0) {
|
||||
return closedStatuses.some((item) => item.trim().toLowerCase() === value);
|
||||
}
|
||||
return CLOSING.has(value);
|
||||
}
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */
|
||||
@@ -141,19 +180,16 @@ export interface Ticket {
|
||||
/** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */
|
||||
fields: Record<string, string | number | boolean | null>;
|
||||
/**
|
||||
* Faze ve workflow **daneho typu**, napr. "ceka na zabaleni".
|
||||
* Uz to nikdo neresi?
|
||||
*
|
||||
* Tri osy zamerne, kazda ma jinou praci:
|
||||
* - `status` je zivotni cyklus (novy, v reseni, ceka, vyreseno). Podle nej
|
||||
* se pocita fronta i statistiky, takze musi zustat pevny.
|
||||
* - `stage` je **postup uvnitr typu** a definuje si ho firma u typu ticketu
|
||||
* (`TicketType.statuses`). Objednavka ma jine faze nez reklamace.
|
||||
* - `tags` jsou volne stitky, ktere spolu nemusi souviset.
|
||||
* Kdyz je stav volny retezec, nejde z nej poznat, jestli je hotovo - `ringing`
|
||||
* a `completed` jsou pro nas jen texty. Fronta, vytizeni i statistiky se
|
||||
* proto ptaji na tenhle priznak, ne na stav.
|
||||
*
|
||||
* Delat faze pres stitky by fungovalo, ale nesla by na nich postavit
|
||||
* kontrola: ticket by mohl mit "ceka na zabaleni" i "expedovano" naraz.
|
||||
* Nastavuje se sam podle stavu (viz `isClosingStatus`), takze bezny uzivatel
|
||||
* o nem nemusi vedet.
|
||||
*/
|
||||
stage: string | null;
|
||||
closed: boolean;
|
||||
/**
|
||||
* Volne oznaceni. Na rozdil od typu jich muze byt vic a nestoji za nimi
|
||||
* zadna pole - proto se hodi na filtry a widgety, ne na akce, ktere
|
||||
@@ -205,6 +241,7 @@ interface StoredTicket
|
||||
| 'stage'
|
||||
| 'assigneeGroupId'
|
||||
| 'externalId'
|
||||
| 'closed'
|
||||
| 'externalSource'
|
||||
| 'firstResponseAt'
|
||||
| 'resolvedAt'
|
||||
@@ -216,7 +253,7 @@ interface StoredTicket
|
||||
typeId?: string | null;
|
||||
fields?: Record<string, string | number | boolean | null>;
|
||||
tags?: string[];
|
||||
stage?: string | null;
|
||||
closed?: boolean;
|
||||
externalId?: string | null;
|
||||
externalSource?: string | null;
|
||||
firstResponseAt?: string | null;
|
||||
@@ -816,7 +853,8 @@ function toTicket(stored: StoredTicket): Ticket {
|
||||
typeId: stored.typeId ?? null,
|
||||
fields: stored.fields ?? {},
|
||||
tags: stored.tags ?? [],
|
||||
stage: stored.stage ?? null,
|
||||
// Starsi zaznamy priznak nemaji, dopocita se ze stavu.
|
||||
closed: stored.closed ?? isClosingStatus(stored.status),
|
||||
externalId: stored.externalId ?? null,
|
||||
externalSource: stored.externalSource ?? null,
|
||||
firstResponseAt: stored.firstResponseAt ?? null,
|
||||
@@ -850,8 +888,6 @@ export interface TicketFilter {
|
||||
channel?: TicketChannel;
|
||||
/** Typ ticketu. `none` = tickety bez typu. */
|
||||
typeId?: string;
|
||||
/** Faze ve workflow typu. `none` = tickety bez faze. */
|
||||
stage?: string;
|
||||
/** Jeden tag. `none` = tickety bez tagu. */
|
||||
tag?: string;
|
||||
/** Skupina resitelu. `none` = bez skupiny. */
|
||||
@@ -865,8 +901,6 @@ export function listTickets(filter: TicketFilter): Ticket[] {
|
||||
if (filter.channel && ticket.channel !== filter.channel) return false;
|
||||
if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId)
|
||||
return false;
|
||||
if (filter.stage === 'none' ? ticket.stage : filter.stage && ticket.stage !== filter.stage)
|
||||
return false;
|
||||
if (filter.tag === 'none') {
|
||||
if ((ticket.tags ?? []).length > 0) return false;
|
||||
} else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) {
|
||||
@@ -887,8 +921,9 @@ export function listTickets(filter: TicketFilter): Ticket[] {
|
||||
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
|
||||
return selected
|
||||
.sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
// Hotove dolu. Ptame se na priznak, ne na text stavu - ten je volny.
|
||||
if (a.closed && !b.closed) return 1;
|
||||
if (b.closed && !a.closed) return -1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
})
|
||||
.map(toTicket);
|
||||
@@ -912,7 +947,7 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef
|
||||
/** Prvni nevyrizeny ticket. */
|
||||
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
|
||||
const stored = tickets.find(
|
||||
(t) => t.status !== 'resolved' && tenantIds.includes(t.tenantId),
|
||||
(t) => !(t.closed ?? isClosingStatus(t.status)) && tenantIds.includes(t.tenantId),
|
||||
);
|
||||
return stored ? toTicket(stored) : undefined;
|
||||
}
|
||||
@@ -945,7 +980,7 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
|
||||
|
||||
const rows = everyone.map((person) => {
|
||||
const mine = visible.filter((t) => t.assigneeId === person.id);
|
||||
const open = mine.filter((t) => t.status !== 'resolved');
|
||||
const open = mine.filter((t) => !(t.closed ?? isClosingStatus(t.status)));
|
||||
const oldest = open.reduce<string | null>(
|
||||
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
|
||||
null,
|
||||
@@ -966,8 +1001,10 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
|
||||
|
||||
return {
|
||||
rows,
|
||||
unassigned: visible.filter((t) => t.assigneeId === null && t.status !== 'resolved').length,
|
||||
openTotal: visible.filter((t) => t.status !== 'resolved').length,
|
||||
unassigned: visible.filter(
|
||||
(t) => t.assigneeId === null && !(t.closed ?? isClosingStatus(t.status)),
|
||||
).length,
|
||||
openTotal: visible.filter((t) => !(t.closed ?? isClosingStatus(t.status))).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1172,7 +1209,9 @@ export function getAgentStats(
|
||||
const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId));
|
||||
|
||||
const rows = everyone.map((person) => {
|
||||
const open = visible.filter((t) => t.assigneeId === person.id && t.status !== 'resolved');
|
||||
const open = visible.filter(
|
||||
(t) => t.assigneeId === person.id && !(t.closed ?? isClosingStatus(t.status)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv,
|
||||
@@ -1237,12 +1276,13 @@ export interface CreateTicketInput {
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
priority: TicketPriority;
|
||||
/** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */
|
||||
status?: string;
|
||||
assigneeId?: string | null;
|
||||
assigneeGroupId?: string | null;
|
||||
typeId?: string | null;
|
||||
fields?: Record<string, string | number | boolean | null>;
|
||||
tags?: string[];
|
||||
stage?: string | null;
|
||||
automationId?: string | null;
|
||||
/** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */
|
||||
trace?: TraceInput[];
|
||||
@@ -1273,7 +1313,8 @@ export function createTicket(input: CreateTicketInput): Ticket {
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
channel: input.channel,
|
||||
customer: input.customer,
|
||||
status: 'new',
|
||||
// Vychozi stav je jen doporuceni. Kdo posle vlastni, ma vlastni.
|
||||
status: input.status ?? defaultStatuses[0],
|
||||
priority: input.priority,
|
||||
assigneeId,
|
||||
// Tyhle ctyri se driv zahazovaly: vstup je nabizel, ale zaznam je nemel.
|
||||
@@ -1282,7 +1323,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
|
||||
typeId: input.typeId ?? null,
|
||||
fields: input.fields ?? {},
|
||||
tags: input.tags ?? [],
|
||||
stage: input.stage ?? null,
|
||||
closed: isClosingStatus(input.status ?? defaultStatuses[0]),
|
||||
automationId: input.automationId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -1314,10 +1355,18 @@ function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmeni stav.
|
||||
*
|
||||
* Stav je **volny retezec**, takze se neoveruje proti ciselniku. Kdyz typ
|
||||
* ticketu ma vlastni workflow, `closedStatuses` rekne, ktere z nich znamenaji
|
||||
* hotovo - jinak se to pozna podle bezneho pojmenovani (`vyreseno`, `completed`).
|
||||
*/
|
||||
export function updateTicketStatus(
|
||||
id: string,
|
||||
status: TicketStatus,
|
||||
tenantIds: string[],
|
||||
closedStatuses?: string[],
|
||||
): Ticket | undefined {
|
||||
const ticket = findWritable(id, tenantIds);
|
||||
if (!ticket) {
|
||||
@@ -1327,13 +1376,14 @@ export function updateTicketStatus(
|
||||
|
||||
const previous = ticket.status;
|
||||
ticket.status = status;
|
||||
ticket.closed = isClosingStatus(status, closedStatuses);
|
||||
|
||||
if (status === 'resolved') {
|
||||
if (ticket.closed) {
|
||||
ticket.resolvedAt = new Date().toISOString();
|
||||
// Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane -
|
||||
// radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel.
|
||||
ticket.resolvedById = ticket.assigneeId;
|
||||
} else if (previous === 'resolved') {
|
||||
} else if (isClosingStatus(previous, closedStatuses)) {
|
||||
// Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo.
|
||||
ticket.reopenCount = (ticket.reopenCount ?? 0) + 1;
|
||||
ticket.resolvedAt = null;
|
||||
@@ -1351,7 +1401,7 @@ export function updateTicketStatus(
|
||||
},
|
||||
]);
|
||||
|
||||
if (status === 'resolved') {
|
||||
if (ticket.closed) {
|
||||
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
|
||||
ticketId: ticket.id,
|
||||
});
|
||||
@@ -1452,41 +1502,6 @@ export function setTicketType(
|
||||
}
|
||||
|
||||
/** Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala. */
|
||||
/**
|
||||
* Nastavi fazi.
|
||||
*
|
||||
* Faze musi byt z workflow daneho typu. Cizi hodnota se odmitne - jinak by
|
||||
* se do dat dostal preklep a podminka nad fazi by tise prestala platit.
|
||||
*/
|
||||
export function setTicketStage(
|
||||
id: string,
|
||||
stage: string | null,
|
||||
allowed: string[],
|
||||
tenantIds: string[],
|
||||
): Ticket | undefined {
|
||||
const ticket = findWritable(id, tenantIds);
|
||||
if (!ticket) return undefined;
|
||||
|
||||
if (stage !== null && allowed.length > 0 && !allowed.includes(stage)) {
|
||||
console.warn(`[tickets] ${id}: faze ${stage} neni ve workflow typu`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previous = ticket.stage ?? null;
|
||||
ticket.stage = stage;
|
||||
touch(ticket);
|
||||
|
||||
appendTrace(id, [
|
||||
{
|
||||
kind: 'note',
|
||||
status: 'info',
|
||||
label: `Fáze: ${previous ?? 'bez fáze'} -> ${stage ?? 'bez fáze'}`,
|
||||
},
|
||||
]);
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má novou fázi`, { ticketId: ticket.id });
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
export function setTicketTags(id: string, tags: string[], tenantIds: string[]): Ticket | undefined {
|
||||
const ticket = findWritable(id, tenantIds);
|
||||
if (!ticket) return undefined;
|
||||
|
||||
+13
-1
@@ -37,8 +37,20 @@ export interface TicketType extends TenantEntity {
|
||||
name: string;
|
||||
/** Klic ikony, mapuje se na klientovi. */
|
||||
icon: string;
|
||||
/** Vlastni workflow stavu. Prazdne = vychozi ctverice ticketu. */
|
||||
/**
|
||||
* Vlastni stavy ticketu. Prazdne = vychozi doporucena ctverice.
|
||||
*
|
||||
* Stav je volny retezec, tohle je jen nabidka v rozhrani - ticket muze mit
|
||||
* i stav, ktery poslala cizi aplikace a v seznamu neni.
|
||||
*/
|
||||
statuses: string[];
|
||||
/**
|
||||
* Ktere z tech stavu znamenaji, ze uz to nikdo neresi.
|
||||
*
|
||||
* Fronta a statistiky se ptaji na tohle, ne na text stavu. Prazdne = pozna
|
||||
* se to podle bezneho pojmenovani (`vyreseno`, `hotovo`, `completed`).
|
||||
*/
|
||||
closedStatuses?: string[];
|
||||
fields: TicketTypeField[];
|
||||
}
|
||||
|
||||
|
||||
+49
-16
@@ -48,10 +48,11 @@ import { listNotifications, markRead, unreadCount } from '../data/notifications.
|
||||
import { queueStats, recentRuns } from '../runtime/queue.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
|
||||
import { listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
findTicket,
|
||||
getAgentStats,
|
||||
getTicket,
|
||||
getWorkload,
|
||||
@@ -59,7 +60,6 @@ import {
|
||||
updateTicketStatus,
|
||||
type TicketChannel,
|
||||
type TicketFilter,
|
||||
type TicketStatus,
|
||||
} from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateRules } from '../scripts/mapping.js';
|
||||
@@ -264,10 +264,10 @@ dashboardRouter.get('/people/:id', (req, res) => {
|
||||
.filter((group) => group.personIds.includes(person.id))
|
||||
.map((group) => ({ id: group.id, name: group.name })),
|
||||
/** Co ma prave ted u sebe. */
|
||||
open: tickets.filter((ticket) => ticket.status !== 'resolved'),
|
||||
open: tickets.filter((ticket) => !ticket.closed),
|
||||
/** Co za posledni dobu vyresil, nejnovejsi nahore. */
|
||||
resolved: resolvedRecently
|
||||
.filter((ticket) => ticket.status === 'resolved')
|
||||
.filter((ticket) => ticket.closed)
|
||||
.sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? ''))
|
||||
.slice(0, 20),
|
||||
});
|
||||
@@ -398,7 +398,7 @@ function myOpenTickets(req: Request): number {
|
||||
return listTickets({
|
||||
tenantIds: access.tenants.map((tenant) => tenant.id),
|
||||
assignee: access.personId,
|
||||
}).filter((ticket) => ticket.status !== 'resolved').length;
|
||||
}).filter((ticket) => !ticket.closed).length;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- fronta
|
||||
@@ -452,7 +452,11 @@ dashboardRouter.get('/people', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
|
||||
/*
|
||||
* Stav uz neni ciselnik. Filtr proto bere, co prijde - kdyz to na nic nesedi,
|
||||
* vrati se prazdny seznam, coz je spravna odpoved na "ukaz mi stav, ktery
|
||||
* nikdo nema".
|
||||
*/
|
||||
const ticketChannels: TicketChannel[] = [
|
||||
'whatsapp',
|
||||
'facebook',
|
||||
@@ -475,8 +479,6 @@ function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope):
|
||||
if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId;
|
||||
const tag = query.tag;
|
||||
if (typeof tag === 'string' && tag !== '') filter.tag = tag;
|
||||
const stage = query.stage;
|
||||
if (typeof stage === 'string' && stage !== '') filter.stage = stage;
|
||||
const groupId = query.groupId;
|
||||
if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId;
|
||||
|
||||
@@ -493,12 +495,13 @@ function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope):
|
||||
}
|
||||
|
||||
function applyRest(query: Record<string, unknown>, filter: TicketFilter): TicketFilter {
|
||||
|
||||
/*
|
||||
* Stav uz neni ciselnik, takze se nekontroluje proti seznamu. Kdyz hodnota
|
||||
* na nic nesedi, vrati se prazdny seznam - to je spravna odpoved na dotaz
|
||||
* po stavu, ktery nikdo nema.
|
||||
*/
|
||||
const status = typeof query.status === 'string' ? query.status : undefined;
|
||||
if (status) {
|
||||
if (ticketStatuses.includes(status as TicketStatus)) filter.status = status as TicketStatus;
|
||||
else console.warn(`[tickets] neznamy stav ve filtru: ${status}`);
|
||||
}
|
||||
if (status) filter.status = status;
|
||||
|
||||
const channel = typeof query.channel === 'string' ? query.channel : undefined;
|
||||
if (channel) {
|
||||
@@ -516,8 +519,23 @@ dashboardRouter.get('/tickets', (req, res) => {
|
||||
const me = findPersonByEmail(req.user!.email);
|
||||
const filter = ticketFilterFrom(req.query as Record<string, unknown>, scope);
|
||||
|
||||
const items = listTickets(filter);
|
||||
|
||||
/*
|
||||
* Stavy, ktere firma opravdu pouziva. Stav je volny retezec, takze pevny
|
||||
* seznam v rozhrani by u ticketu z cizich aplikaci nikdy nesedel - filtr
|
||||
* musi nabidnout to, co v datech je.
|
||||
*
|
||||
* Bere se z celeho rozsahu, ne z vyfiltrovaneho vysledku: jinak by po
|
||||
* kliknuti na jeden stav zmizely ostatni a nesel by prepnout.
|
||||
*/
|
||||
const statuses = [
|
||||
...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)),
|
||||
].sort((a, b) => a.localeCompare(b, 'cs'));
|
||||
|
||||
return res.json({
|
||||
items: listTickets(filter),
|
||||
items,
|
||||
statuses,
|
||||
meId: me?.id ?? null,
|
||||
scope: scope.scope,
|
||||
tenantId: scope.tenantId,
|
||||
@@ -584,7 +602,12 @@ dashboardRouter.post('/tickets/:id/assign', (req, res) => {
|
||||
});
|
||||
|
||||
const statusSchema = z.object({
|
||||
status: z.enum(['new', 'open', 'waiting', 'resolved']),
|
||||
/*
|
||||
* Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere
|
||||
* maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl
|
||||
* nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument.
|
||||
*/
|
||||
status: z.string().trim().min(1).max(60),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
@@ -593,7 +616,17 @@ dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
|
||||
}
|
||||
|
||||
const ticket = updateTicketStatus(req.params.id, parsed.data.status, writableTenants(req));
|
||||
// Typ rika, ktere z jeho stavu znamenaji hotovo. Bez nej se to pozna
|
||||
// podle bezneho pojmenovani (vyreseno, completed).
|
||||
const current = findTicket(req.params.id, writableTenants(req));
|
||||
const type = current?.typeId ? findTicketType(current.typeId) : undefined;
|
||||
|
||||
const ticket = updateTicketStatus(
|
||||
req.params.id,
|
||||
parsed.data.status,
|
||||
writableTenants(req),
|
||||
type?.closedStatuses,
|
||||
);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ const sourceSchema = z.discriminatedUnion('kind', [
|
||||
kind: z.literal('ticketCount'),
|
||||
filter: z.record(z.unknown()).default({}),
|
||||
groupBy: z
|
||||
.enum(['assignee', 'group', 'status', 'stage', 'type', 'tag', 'channel'])
|
||||
.enum(['assignee', 'group', 'status', 'type', 'tag', 'channel'])
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
|
||||
@@ -179,8 +179,6 @@ function groupLabel(key: string, groupBy: WidgetGroupBy): string {
|
||||
return key === '' ? 'Bez typu' : (findTicketType(key)?.name ?? key);
|
||||
case 'status':
|
||||
return statusLabels[key] ?? key;
|
||||
case 'stage':
|
||||
return key === '' ? 'Bez fáze' : key;
|
||||
case 'channel':
|
||||
return channelLabels[key as keyof typeof channelLabels] ?? key;
|
||||
case 'tag':
|
||||
@@ -200,8 +198,6 @@ function groupKeys(ticket: Ticket, groupBy: string): string[] {
|
||||
return [ticket.typeId ?? ''];
|
||||
case 'status':
|
||||
return [ticket.status];
|
||||
case 'stage':
|
||||
return [ticket.stage ?? ''];
|
||||
case 'channel':
|
||||
return [ticket.channel];
|
||||
case 'tag':
|
||||
@@ -381,7 +377,6 @@ function ticketsHref(
|
||||
assignee: 'assignee',
|
||||
group: 'groupId',
|
||||
status: 'status',
|
||||
stage: 'stage',
|
||||
type: 'typeId',
|
||||
tag: 'tag',
|
||||
channel: 'channel',
|
||||
|
||||
+15
-56
@@ -19,14 +19,12 @@ import {
|
||||
findTicket,
|
||||
getWorkload,
|
||||
intakeEvent,
|
||||
setTicketStage,
|
||||
setTicketTags,
|
||||
setTicketType,
|
||||
updateTicketStatus,
|
||||
type Ticket,
|
||||
type TicketChannel,
|
||||
type TicketPriority,
|
||||
type TicketStatus,
|
||||
} from '../data/ticketStore.js';
|
||||
|
||||
export interface StepContext {
|
||||
@@ -54,7 +52,6 @@ function missing(what: string): StepOutcome {
|
||||
return { ok: false, summary: `chybí ${what}`, detail: null, outputs: {} };
|
||||
}
|
||||
|
||||
const statuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
|
||||
const priorities: TicketPriority[] = ['low', 'normal', 'high', 'critical'];
|
||||
const channels: TicketChannel[] = [
|
||||
'whatsapp',
|
||||
@@ -150,33 +147,17 @@ const handlers: Record<string, Handler> = {
|
||||
});
|
||||
|
||||
/*
|
||||
* Stav a faze se nastavuji az po zalozeni: u noveho ticketu jeste neni typ,
|
||||
* takze by se faze nemela proti cemu overit.
|
||||
*
|
||||
* `status` je shovivavy zamerne. Cizi aplikace posila svoje stavy
|
||||
* (`ringing`, `completed`) a nas zivotni cyklus ma pevne ctyri hodnoty.
|
||||
* Kdyz hodnota mezi ne patri, nastavi se stav; kdyz ne, ulozi se jako
|
||||
* **faze**, protoze to presne je - postup uvnitr typu. Do shrnuti kroku
|
||||
* se napise, co se stalo, aby to nebylo kouzlo.
|
||||
* Stav se nastavuje az po zalozeni, aby se dohledal typ a jeho workflow.
|
||||
* Je to **volny retezec**: co posle cizi aplikace, to se ulozi. Podle
|
||||
* `closedStatuses` typu se pozna, jestli to znamena hotovo.
|
||||
*/
|
||||
const wanted = inputs.status?.trim();
|
||||
const asStage = inputs.stage?.trim();
|
||||
let note = '';
|
||||
|
||||
if (wanted && statuses.includes(wanted as TicketStatus)) {
|
||||
updateTicketStatus(result.ticket.id, wanted as TicketStatus, [context.tenantId]);
|
||||
note = `, stav ${wanted}`;
|
||||
}
|
||||
|
||||
const stage = asStage || (wanted && !statuses.includes(wanted as TicketStatus) ? wanted : '');
|
||||
if (stage) {
|
||||
if (wanted) {
|
||||
const type = result.ticket.typeId ? findTicketType(result.ticket.typeId) : undefined;
|
||||
const changed = setTicketStage(result.ticket.id, stage, type?.statuses ?? [], [
|
||||
context.tenantId,
|
||||
]);
|
||||
note += changed
|
||||
? `, fáze ${stage}`
|
||||
: `, fáze ${stage} NEULOŽENA (není ve workflow typu)`;
|
||||
updateTicketStatus(result.ticket.id, wanted, [context.tenantId], type?.closedStatuses);
|
||||
note = `, stav ${wanted}`;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -351,44 +332,22 @@ const handlers: Record<string, Handler> = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Posune ticket do dalsi faze.
|
||||
* Zmeni stav ticketu.
|
||||
*
|
||||
* Faze je z workflow typu, takze `objednavka` ma jine nez `reklamace`.
|
||||
* Cizi hodnota se odmitne - preklep by tise vyradil podminku nad fazi.
|
||||
* Stav je volny retezec, takze se neoveruje proti ciselniku. Kdyz ma typ
|
||||
* vlastni workflow, nabidne se v nastaveni kroku - ale nic nebrani poslat
|
||||
* hodnotu, kterou posila cizi aplikace.
|
||||
*/
|
||||
'ticket/set-stage': (inputs, context) => {
|
||||
const ticketId = inputs.ticketId?.trim() || context.ticketId;
|
||||
if (!ticketId) return missing('ticket');
|
||||
|
||||
const stage = inputs.stage?.trim();
|
||||
if (!stage) return missing('fáze');
|
||||
|
||||
const ticket = findTicket(ticketId, [context.tenantId]);
|
||||
const type = ticket?.typeId ? findTicketType(ticket.typeId) : undefined;
|
||||
const allowed = type?.statuses ?? [];
|
||||
|
||||
const updated = setTicketStage(ticketId, stage, allowed, [context.tenantId]);
|
||||
if (!updated) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: `fáze ${stage} není ve workflow typu`,
|
||||
detail: allowed.length > 0 ? `Typ dovoluje: ${allowed.join(', ')}.` : null,
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, summary: stage, detail: null, outputs: { stage } };
|
||||
},
|
||||
|
||||
/** Zmeni stav ticketu. */
|
||||
'ticket/set-status': (inputs, context) => {
|
||||
const ticketId = inputs.ticketId?.trim() || context.ticketId;
|
||||
if (!ticketId) return missing('ticket');
|
||||
|
||||
const status = inputs.status?.trim() as TicketStatus;
|
||||
if (!statuses.includes(status)) return missing('platný stav');
|
||||
const status = inputs.status?.trim();
|
||||
if (!status) return missing('stav');
|
||||
|
||||
const updated = updateTicketStatus(ticketId, status, [context.tenantId]);
|
||||
const ticket = findTicket(ticketId, [context.tenantId]);
|
||||
const type = ticket?.typeId ? findTicketType(ticket.typeId) : undefined;
|
||||
const updated = updateTicketStatus(ticketId, status, [context.tenantId], type?.closedStatuses);
|
||||
if (!updated) return { ok: false, summary: 'stav se nepodařilo změnit', outputs: {} };
|
||||
return { ok: true, summary: status, detail: null, outputs: { status } };
|
||||
},
|
||||
|
||||
@@ -169,7 +169,6 @@ export function ticketPayload(ticket: Ticket): Record<string, unknown> {
|
||||
priority: ticket.priority,
|
||||
channel: ticket.channel,
|
||||
typeId: ticket.typeId,
|
||||
stage: ticket.stage,
|
||||
tags: ticket.tags,
|
||||
assigneeId: ticket.assignee?.id ?? null,
|
||||
/*
|
||||
|
||||
@@ -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