From 52999a961b2603f56bad3a95e4478997336256f8 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:37:55 +0200 Subject: [PATCH] Nezarazene vidi kazdy, a dlazdice "Moje tickety" Dve veci, ktere ze stropu viditelnosti vypadly. Nezarazeny ticket je ve stropu vzdycky. Prvni verze ho schovavala: kdo nevidel na celou firmu, nevidel ticket bez resitele a bez skupiny. Byla to diera v provozu - prichozi ticket, ktery jeste nikdo nesmeroval, nepatri do zadne sekce, takze by ho nevidel nikdo krome vedeni a nikdo by si ho nevzal. Fronta je spolecna, prave proto je to fronta. Jakmile si ho nekdo vezme, plati strop jako u kazdeho jineho. Dlazdice "Moje tickety" a "Fronta bez resitele" byly v navrhu od zacatku a nikdy se neudelaly. Presne jak navrh rikal: zaznam v katalogu a zdroj v builtinSources, zadna nova komponenta, data pocita tataz cesta jako u vykonu resitelu. Tri veci, ktere si to vyzadalo: - closed ve WidgetTicketFilter. Bez nej se vyrizene tickety nedaly odfiltrovat: stav je volny retezec a vyjmenovat vsechny podoby slova "hotovo" se neda. Prospeje to i vlastnim widgetum, dosud neslo postavit "otevrene tickety podle typu" - ResolvedScope.personId se plni vzdycky, ne jen u pohledu mine. Prehled se pta v pohledu tenant, takze filtr me nemel co dosadit a dlazdice vracela prazdno. Kdo je to "ja", na pohledu nezalezi - kdo neni veden jako resitel, tomu se "Moje tickety" nenabidnou. Ticket se prirazuje resiteli, ne uctu Vychozi rozlozeni ma nahore to, co clovek muze udelat, teprve pod tim cisla. Graf behu z vychozi sady ven, je to nejmene srozumitelna dlazdice pro noveho cloveka a zabira celou sirku. V katalogu zustava. Zmena se projevi jen tem, kdo si dashboard jeste neupravili. Overeno na bezici instanci: Kriz jako radovy clen sekce vidi ve svych ticketech TK-4820, ve fronte nezarazeny TK-4819 a v seznamu oba. Pred opravou personId vracela prvni dlazdice prazdno. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/07-firmy-a-prava.md | 12 +++-- documentation/25-navrh-pristupny-portal.md | 16 ++++-- documentation/99-zmeny.md | 62 ++++++++++++++++++++++ src/data/access.ts | 15 ++++-- src/data/customWidgets.ts | 7 +++ src/data/dashboardLayouts.ts | 21 +++++--- src/data/ticketStore.ts | 9 +++- src/data/widgets.ts | 57 ++++++++++++++++++-- src/routes/dashboard.ts | 6 ++- src/routes/widgetData.ts | 1 + web/src/pages/dashboard/Overview.tsx | 2 +- 11 files changed, 184 insertions(+), 24 deletions(-) diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index 4db56c7..fdaf44c 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -249,7 +249,13 @@ Tataz hierarchie, jen "moje" znamena neco jineho: zadavatel pozadavek nikdy nema prirazeny. Ticket proto nese `createdById` a plati, ze kdo vidi celou firmu, vidi vsechny jeji pozadavky, ostatni jen ty svoje. -### Co z toho plyne +### Nezarazene vidi kazdy -Neprirazeny ticket **bez skupiny** nevidi nikdo krome toho, kdo vidi celou -firmu. Prichozi praci musi nekdo smerovat, jinak se k radovym clenum nedostane. +Ticket **bez resitele** je ve stropu vzdycky, at uz ma skupinu nebo ne. + +Prvni verze ho schovavala a byla to diera v provozu: prichozi ticket, ktery +jeste nikdo nesmeroval, nepatri do zadne sekce, takze by ho nevidel nikdo +krome vedeni - a nikdo by si ho nevzal. **Fronta je spolecna, prave proto je +to fronta.** + +Jakmile si ho nekdo vezme, plati strop jako u kazdeho jineho ticketu. diff --git a/documentation/25-navrh-pristupny-portal.md b/documentation/25-navrh-pristupny-portal.md index 1e76a69..e62ad0f 100644 --- a/documentation/25-navrh-pristupny-portal.md +++ b/documentation/25-navrh-pristupny-portal.md @@ -1,10 +1,16 @@ # 25 - Navrh: pristupny portal a viditelnost -**Navrh, ne popis stavu.** Hotove uz jsou sekce 4 (viditelnost, vcetne zalozky -Firma) a 6, ostatni zatim naprogramovane neni. Popis toho, jak viditelnost -opravdu funguje, je v [07-firmy-a-prava.md](07-firmy-a-prava.md). Az se cast udela, prepise se do prislusneho souboru -dokumentace a odsud zmizi. Stejne pravidlo jako -u [09-navrh-rozsireni.md](09-navrh-rozsireni.md). +**Navrh, ne popis stavu.** Hotove uz jsou: + +- sekce 4, viditelnost a zalozka Firma - jak to funguje, popisuje + [07-firmy-a-prava.md](07-firmy-a-prava.md), +- sekce 6, kontrakt webhooku, +- z prvni sekce dlazdice "Moje tickety" a "Fronta bez resitele" vcetne noveho + vychoziho rozlozeni. + +Zbyva widget akci, "Zaciname", formularova vrstva a hledani. Az se cast udela, +prepise se do prislusneho souboru dokumentace a odsud zmizi - stejne pravidlo +jako u [09-navrh-rozsireni.md](09-navrh-rozsireni.md). Popis soucasneho stavu je v [01-prehled-a-stav.md](01-prehled-a-stav.md), prava a pohledy v [07-firmy-a-prava.md](07-firmy-a-prava.md), widgety diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index c2f330f..82db638 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,68 @@ Nejnovejsi nahore. +## 2026-09-02 - Nezarazene vidi kazdy, a dlazdice "Moje tickety" + +Dve veci, ktere ze stropu viditelnosti vypadly. + +### Nezarazeny ticket je ve stropu vzdycky + +Prvni verze ho schovavala: kdo nevidel na celou firmu, nevidel ticket, ktery +nema resitele ani skupinu. Byla to diera v provozu. Prichozi ticket, ktery +jeste nikdo nesmeroval, nepatri do zadne sekce, takze by ho nevidel nikdo krome +vedeni - a nikdo by si ho nevzal. **Fronta je spolecna, prave proto je to +fronta.** + +`withinVisibility` proto pousti kazdy ticket bez resitele, at uz ma skupinu nebo +ne. Jakmile si ho nekdo vezme, plati strop jako u kazdeho jineho. + +### Dlazdice "Moje tickety" a "Fronta bez resitele" + +Byly v navrhu od zacatku a nikdy se neudelaly. Presne jak navrh rikal, staci +zaznam v katalogu a zdroj v `builtinSources` - **zadna nova komponenta**, data +pocita tatáz cesta jako u vykonu resitelu. + +```ts +'list.myTickets': { kind: 'ticketList', filter: { assignee: ['me'], closed: false }, limit: 8 }, +'list.unassigned': { kind: 'ticketList', filter: { assignee: ['unassigned'], closed: false }, limit: 8 }, +``` + +K tomu tri veci, ktere si to vyzadalo: + +- **`closed` ve `WidgetTicketFilter`.** Bez nej se vyrizene tickety nedaly + odfiltrovat: stav je volny retezec a vyjmenovat vsechny podoby slova "hotovo" + se neda. Prospeje to i vlastnim widgetum, dosud neslo postavit "otevrene + tickety podle typu". +- **`ResolvedScope.personId` se plni vzdycky**, ne jen u pohledu `mine`. Prehled + se pta v pohledu `tenant`, takze filtr `me` nemel co dosadit a dlazdice + vracela prazdno. Kdo je to "ja", na pohledu nezalezi. +- **Kdo neni veden jako resitel, tomu se "Moje tickety" nenabidnou.** Ticket se + prirazuje resiteli, ne uctu, takze by takovy clovek koukal na prazdno navzdy + a nedozvedel se proc. + +### Vychozi rozlozeni + +Nahore to, co clovek muze udelat, teprve pod tim cisla: + +```text +Moje tickety, Fronta bez resitele +Aktivni automatizace, Otevrene tickety, Bezici incidenty +Posledni tickety, Incidenty +``` + +Graf behu z vychozi sady ven. Je to nejmene srozumitelna dlazdice pro noveho +cloveka - behy ceho a co s tim - a zabira celou sirku. V katalogu zustava. + +**Zmena se projevi jen tem, kdo si dashboard jeste neupravili.** Rozlozeni se +uklada za dvojici uzivatel a firma, kdo uz si ho osahal, musi dlazdice pridat +rucne nebo dat "Vychozi". + +### Overeno na bezici instanci + +Kriz jako radovy clen sekce: "Moje tickety" vraci jeho TK-4820, "Fronta bez +resitele" nezarazeny TK-4819, a v seznamu ticketu vidi oba. Pred opravou +`personId` vracela prvni dlazdice prazdno. + ## 2026-09-02 - Detail ticketu ma rozradovace, ne tri karty pod sebou Navrh v [25](25-navrh-pristupny-portal.md) mel detail jako jednu slozku diff --git a/src/data/access.ts b/src/data/access.ts index fa4a224..2d3ee9a 100644 --- a/src/data/access.ts +++ b/src/data/access.ts @@ -172,7 +172,16 @@ export interface ResolvedScope { tenantIds: string[]; /** Firma, na kterou je pohled zamereny. null u pohledu `all`. */ tenantId: string | null; - /** Vyplnene jen u pohledu `mine`. */ + /** + * Resitel prihlaseneho cloveka, nebo null. + * + * Vyplnene **vzdycky**, ne jen u pohledu `mine`. Driv se plnilo jen tam + * a widget "moje tickety" proto na prehledu vracel prazdno: prehled se + * pta v pohledu `tenant`, takze filtr `me` nemel co dosadit. + * + * Kdo je to "ja", na pohledu nezalezi. Jestli se podle toho ma filtrovat, + * rozhoduje volajici - `ticketFilterFrom` se pta na `scope.scope`. + */ personId: string | null; /** * Strop viditelnosti. **Pohled se o nej orizne**, nikdy naopak. @@ -228,7 +237,7 @@ export function resolveScope( scope, tenantIds: access.tenants.map((tenant) => tenant.id), tenantId: null, - personId: null, + personId: access.personId, visibility: { kind: 'all' }, }; } @@ -251,7 +260,7 @@ export function resolveScope( scope, tenantIds: [tenantId], tenantId, - personId: scope === 'mine' ? access.personId : null, + personId: access.personId, visibility: visibilityFor(user, tenantId), }; } diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts index fadb6cf..dcec448 100644 --- a/src/data/customWidgets.ts +++ b/src/data/customWidgets.ts @@ -43,6 +43,13 @@ export interface WidgetTicketFilter { assignee?: string[]; groupIds?: string[]; period?: WidgetPeriod; + /** + * Jen vyrizene, nebo jen nevyrizene. Nevyplnene = oboji. + * + * Ptat se na to pres stav nejde: stav je volny retezec a vyjmenovat + * vsechny podoby slova "hotovo" se neda. Priznak `closed` je od toho. + */ + closed?: boolean; } export type WidgetSource = diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts index 58454e0..6936314 100644 --- a/src/data/dashboardLayouts.ts +++ b/src/data/dashboardLayouts.ts @@ -24,14 +24,23 @@ export const MAX_WIDGETS = 12; /** Co uvidi nekdo, kdo si dashboard jeste neupravil. */ const defaultLayout: LayoutItem[] = [ - { id: 'w1', widgetId: 'stat.activeAutomations', size: 'third' }, - { id: 'w2', widgetId: 'stat.openTickets', size: 'third' }, - { id: 'w3', widgetId: 'stat.activeIncidents', size: 'third' }, - { id: 'w4', widgetId: 'chart.runs', size: 'full' }, - { id: 'w5', widgetId: 'list.tickets', size: 'half' }, - { id: 'w6', widgetId: 'list.incidents', size: 'half' }, + // Nahore to, co clovek muze udelat, teprve pod tim cisla. Vsechny ostatni + // dlazdice odpovidaji na "jak jsme na tom", tyhle dve na "co mam delat ted". + { id: 'w1', widgetId: 'list.myTickets', size: 'half' }, + { id: 'w2', widgetId: 'list.unassigned', size: 'half' }, + { id: 'w3', widgetId: 'stat.activeAutomations', size: 'third' }, + { id: 'w4', widgetId: 'stat.openTickets', size: 'third' }, + { id: 'w5', widgetId: 'stat.activeIncidents', size: 'third' }, + { id: 'w6', widgetId: 'list.tickets', size: 'half' }, + { id: 'w7', widgetId: 'list.incidents', size: 'half' }, ]; +/* + * Graf behu ve vychozi sade neni schvalne. Je to nejmene srozumitelna + * dlazdice pro noveho cloveka - behy ceho a co s tim - a zabira celou sirku. + * V katalogu zustava, kdo ho chce, prida si ho. + */ + const layouts = new Map(); /** diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 1769136..53dc2b0 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -950,7 +950,14 @@ export interface TicketFilter { */ function withinVisibility(ticket: StoredTicket, visibility: Visibility): boolean { if (visibility.kind === 'all') return true; - if (ticket.assigneeId && visibility.personIds.includes(ticket.assigneeId)) return true; + /* + * **Nezarazene vidi kazdy ve firme.** Prvni verze stropu je schovavala + * a byla to diera v provozu: prichozi ticket, ktery jeste nikdo nesmeroval, + * nepatri do zadne sekce, takze by ho nevidel nikdo krome vedeni - a nikdo + * by si ho nevzal. Fronta je spolecna, prave proto je to fronta. + */ + if (ticket.assigneeId === null) return true; + if (visibility.personIds.includes(ticket.assigneeId)) return true; if (ticket.assigneeGroupId && visibility.groupIds.includes(ticket.assigneeGroupId)) return true; return false; } diff --git a/src/data/widgets.ts b/src/data/widgets.ts index 5f3f259..22c667c 100644 --- a/src/data/widgets.ts +++ b/src/data/widgets.ts @@ -132,6 +132,31 @@ export const widgets: WidgetDefinition[] = [ sizes: ['half', 'full'], defaultSize: 'full', }, + /* + * Dve dlazdice, ktere odpovidaji na "co mam delat ted". + * + * Vsechny ostatni odpovidaji na "jak jsme na tom" - jsou to statistiky + * a seznamy. Tyhle dve delaji z prehledu pracovni plochu misto reportu. + * + * Data pocita server pres `builtinSources` nize, stejne jako u vykonu + * resitelu, takze k nim neni potreba zadna nova komponenta. + */ + { + id: 'list.myTickets', + name: 'Moje tickety', + description: 'Co mám právě teď u sebe, nevyřízené nahoře.', + kind: 'list', + sizes: ['half', 'full'], + defaultSize: 'half', + }, + { + id: 'list.unassigned', + name: 'Fronta bez řešitele', + description: 'Co ještě nikdo nemá u sebe. Vidí ji každý, kdo do firmy patří.', + kind: 'list', + sizes: ['half', 'full'], + defaultSize: 'half', + }, ]; /** @@ -141,8 +166,23 @@ export const widgets: WidgetDefinition[] = [ * Diky tomu nemusi mit "Vykon resitelu" vlastni endpoint ani vlastni cestu * v klientovi - je to tentyz zdroj, jen ho nesklada uzivatel. */ -export const builtinSources: Record = { +export const builtinSources: Record = { 'panel.agents': { kind: 'agentStats', period: '30d' }, + /* + * `me` a `unassigned` umi filtr uz davno, jen je zatim nikdo nepouzil na + * vestaveny widget. `closed: false` je tam schvalne: vyrizene tickety na + * prehledu nikoho nezajimaji a bez toho by zabraly misto tem, co hori. + */ + 'list.myTickets': { + kind: 'ticketList', + filter: { assignee: ['me'], closed: false }, + limit: 8, + }, + 'list.unassigned': { + kind: 'ticketList', + filter: { assignee: ['unassigned'], closed: false }, + limit: 8, + }, }; export function findWidget(id: string): WidgetDefinition | undefined { @@ -166,7 +206,11 @@ export interface CatalogWidget extends WidgetDefinition { * znala jen pevne widgety a ulozit rozlozeni s vlastnim widgetem vzdycky * selhalo hlaskou, ze v katalogu neni. */ -export function widgetCatalog(tenantIds: string[], userId: string): CatalogWidget[] { +export function widgetCatalog( + tenantIds: string[], + userId: string, + hasPerson: boolean, +): CatalogWidget[] { const custom = listCustomWidgets(tenantIds, userId).map( (widget): CatalogWidget => ({ id: widget.id, @@ -183,5 +227,12 @@ export function widgetCatalog(tenantIds: string[], userId: string): CatalogWidge }), ); - return [...widgets.map((widget): CatalogWidget => ({ ...widget, custom: false })), ...custom]; + /* + * Kdo neni veden jako resitel, nema "moje tickety" co ukazat: ticket se + * prirazuje resiteli, ne uctu. Nabidnout mu prazdnou dlazdici by znamenalo, + * ze uvidi prazdno navzdy a nedozvi se proc. + */ + const builtin = widgets.filter((widget) => hasPerson || widget.id !== 'list.myTickets'); + + return [...builtin.map((widget): CatalogWidget => ({ ...widget, custom: false })), ...custom]; } diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 6061634..7baf168 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -265,7 +265,9 @@ dashboardRouter.get('/widgets', (req, res) => { const tenantId = layoutTenant(req, res); if (!tenantId) return; - return res.json({ items: widgetCatalog([tenantId], req.user!.id) }); + return res.json({ + items: widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null), + }); }); /** @@ -324,7 +326,7 @@ dashboardRouter.put('/layout', (req, res) => { const problems = validateLayout( parsed.data.items, - widgetCatalog([tenantId], req.user!.id), + widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null), ); if (problems.length > 0) { console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`); diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index cb8ce7b..c01e145 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -138,6 +138,7 @@ function since(period: WidgetTicketFilter['period']): number | null { * da zapomenout. */ function matches(ticket: Ticket, filter: WidgetTicketFilter, personId: string | null): boolean { + if (filter.closed !== undefined && ticket.closed !== filter.closed) return false; if (filter.status && filter.status.length > 0 && !filter.status.includes(ticket.status)) return false; if (filter.channel && filter.channel.length > 0 && !filter.channel.includes(ticket.channel)) return false; if (filter.typeIds && filter.typeIds.length > 0) { diff --git a/web/src/pages/dashboard/Overview.tsx b/web/src/pages/dashboard/Overview.tsx index 7e1173e..df09a48 100644 --- a/web/src/pages/dashboard/Overview.tsx +++ b/web/src/pages/dashboard/Overview.tsx @@ -46,7 +46,7 @@ const span: Record = { * Vestavene widgety, jejichz data pocita server stejne jako u vlastnich. * Musi sedet s `builtinSources` v `src/data/widgets.ts`. */ -const COMPUTED_BUILTINS = new Set(['panel.agents']); +const COMPUTED_BUILTINS = new Set(['panel.agents', 'list.myTickets', 'list.unassigned']); const sizeLabels: Record = { third: 'třetina',