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
+1 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.' });
}
+1 -1
View File
@@ -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({
-5
View File
@@ -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
View File
@@ -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 } };
},
-1
View File
@@ -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,
/*