From 202d52590511b1c8afcd53b14b6612ae75a653db Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:36:41 +0200 Subject: [PATCH] Stav ticketu je volny retezec, ciselnik pryc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- documentation/99-zmeny.md | 31 ++++ src/data/customWidgets.ts | 17 +-- src/data/mock.ts | 3 +- src/data/ticketStore.ts | 145 ++++++++++--------- src/data/ticketTypes.ts | 14 +- src/routes/dashboard.ts | 65 +++++++-- src/routes/settings.ts | 2 +- src/routes/widgetData.ts | 5 - src/runtime/builtinSteps.ts | 71 ++------- src/runtime/triggers.ts | 1 - web/src/components/dashboard/StatusBadge.tsx | 57 +++++--- web/src/components/dashboard/TicketTable.tsx | 4 +- web/src/pages/dashboard/PersonDetail.tsx | 2 +- web/src/pages/dashboard/TicketDetail.tsx | 50 +++++-- web/src/pages/dashboard/Tickets.tsx | 23 ++- web/src/types/dashboard.ts | 20 ++- 16 files changed, 290 insertions(+), 220 deletions(-) diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 4e72a87..6881d1b 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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 diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts index 6d6e9bf..fadb6cf 100644 --- a/src/data/customWidgets.ts +++ b/src/data/customWidgets.ts @@ -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', diff --git a/src/data/mock.ts b/src/data/mock.ts index 8509e50..7ff6092 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -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, diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 70fea80..40bd0cb 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -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; /** - * 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; 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( (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; 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; diff --git a/src/data/ticketTypes.ts b/src/data/ticketTypes.ts index 394d391..07cb282 100644 --- a/src/data/ticketTypes.ts +++ b/src/data/ticketTypes.ts @@ -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[]; } diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 424df8b..aa0681d 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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, 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, scope: ResolvedScope): } function applyRest(query: Record, 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, 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.' }); } diff --git a/src/routes/settings.ts b/src/routes/settings.ts index b12c26f..554f356 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -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({ diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index 5fed3e0..dfbbc74 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -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', diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index 0e6db28..20769f7 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -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 = { }); /* - * 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 = { }, /** - * 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 } }; }, diff --git a/src/runtime/triggers.ts b/src/runtime/triggers.ts index 652a5d3..78c9427 100644 --- a/src/runtime/triggers.ts +++ b/src/runtime/triggers.ts @@ -169,7 +169,6 @@ export function ticketPayload(ticket: Ticket): Record { priority: ticket.priority, channel: ticket.channel, typeId: ticket.typeId, - stage: ticket.stage, tags: ticket.tags, assigneeId: ticket.assignee?.id ?? null, /* diff --git a/web/src/components/dashboard/StatusBadge.tsx b/web/src/components/dashboard/StatusBadge.tsx index 5490a19..07b3d64 100644 --- a/web/src/components/dashboard/StatusBadge.tsx +++ b/web/src/components/dashboard/StatusBadge.tsx @@ -28,11 +28,27 @@ import type { * text i ikona (pristupnost, viz docs/05-design-system.md). */ -const ticketStatusMap: Record = { - 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 = { + 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 = { @@ -75,34 +91,27 @@ const incidentStatusMap: Record - - - {stage} - - - {label} - - - ); - } + const key = status.trim().toLowerCase(); + const tone = knownStatusTones[key] ?? (closed ? 'ok' : 'neutral'); + const Icon = closed ? CheckCircle2 : key.startsWith('čeká') ? Clock : CircleDot; return ( - {label} + {status} ); } diff --git a/web/src/components/dashboard/TicketTable.tsx b/web/src/components/dashboard/TicketTable.tsx index f226541..57bfdbb 100644 --- a/web/src/components/dashboard/TicketTable.tsx +++ b/web/src/components/dashboard/TicketTable.tsx @@ -101,7 +101,7 @@ export function TicketTable({ )} - + @@ -143,7 +143,7 @@ export function TicketTable({
{ticket.id} - +

{ticket.subject}

diff --git a/web/src/pages/dashboard/PersonDetail.tsx b/web/src/pages/dashboard/PersonDetail.tsx index 08672fc..f6bc277 100644 --- a/web/src/pages/dashboard/PersonDetail.tsx +++ b/web/src/pages/dashboard/PersonDetail.tsx @@ -202,7 +202,7 @@ function TicketCards({ >
{ticket.id} - +

{ticket.subject} diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx index 169fd5a..04a95f6 100644 --- a/web/src/pages/dashboard/TicketDetail.tsx +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -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(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>([]); + + 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() {

{ticket.data.id} - + {ticket.data.externalId && ( @@ -334,9 +360,9 @@ export default function TicketDetail() { } className={cn(selectClass, 'mt-2 w-full')} > - {statusOptions.map((option) => ( - ))} diff --git a/web/src/pages/dashboard/Tickets.tsx b/web/src/pages/dashboard/Tickets.tsx index 7b2e1b6..1a6a0db 100644 --- a/web/src/pages/dashboard/Tickets.tsx +++ b/web/src/pages/dashboard/Tickets.tsx @@ -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() {
- {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) => ( setStatus(status === item.value ? null : item.value)} + key={value} + active={status === value} + onClick={() => setStatus(status === value ? null : value)} > - {item.label} + {value} ))}
@@ -301,7 +298,7 @@ export default function Tickets() { >
{ticket.id} - +

diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 4761020..c749b79 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -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; /** - * 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;