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
@@ -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,
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user