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
+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',