From e701bc0e2ca1d99ea483c6ad366c316e71c31c9b Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:48:15 +0200 Subject: [PATCH] Tickety maji zalozky, fronta umi prirazovat Seznam ticketu mel dva prepinace, "Moje tickety" a "Ve fronte", schovane mezi ostatnimi filtry - splyvaly s nimi a vypadaly jako dalsi dva chipy. Jenze "co mam u sebe", "co jeste nikdo nema" a "co je ve firme" nejsou filtry, jsou to tri ruzne prace a kazda chce jinou tabulku. Tri zalozky: - Moje tickety, pro toho, kdo je veden jako resitel. Bez sloupce Resi, byl by tam porad on - Nezarazene, pro kazdeho. Radky s rychlym prirazenim - Vsechny tickety, jen pro toho, kdo vidi i cizi. Sloupec Resi a filtr na sekce Sloupec Resi je jen ve Vsech: v Mych by ve vsech radcich stalo tyz jmeno a ve fronte je z definice prazdny. Az v prehledu vsech ma smysl videt, kdo co ma u sebe. Zalozka Vsechny se nenabizi tomu, kdo vidi jen svoje - byl by to tentyz seznam. Rika to Access.seesOthers, a Access.visibleGroups k tomu prida sekce, ktere smi filtrovat: kdo vidi celou firmu vsechny, vedouci jen ty, ktere vede. Nova QuickAssign: u kazdeho ticketu ve fronte tlacitko Vzit si a dve roletky, komu a do ktere sekce. Prirazeni je ve fronte hlavni prace, ne jedna z mnoha veci na detailu - otevirat kvuli tomu kazdy ticket znamena u dvaceti ticketech ctyricet kliknuti navic. Dve roletky, ne jedna: clovek a sekce jsou dve ruzna rozhodnuti. Kdo nesmi rozdavat praci ostatnim, vidi jen Vzit si. Endpointy uz existovaly, nova je jen cesta k nim. Vychozi zalozka se odvodi z adresy: odkaz z widgetu nese assignee, takze unassigned otevre frontu a konkretni resitel nebo sekce pohled Vsechny. Bez toho by clovek prisel z dlazdice "fronta bez resitele" a koukal na svoje. Do navrhu 25 zapsano, ze hledani ma zapadnout do zalozky, ve ktere clovek stoji, ne ji obejit, a vysledek se ma vracet do te same zalozky. Overeno na bezici instanci: spravce platformy ma zalozku Vsechny a vsechny sekce ve filtru, Vomacka jako vedouci Servicedesku jen svoji sekci, Kriz jako radovy clen zalozku Vsechny nema. A cely pohyb ticketu frontou: admin preda TK-4819 do sekce, Kriz ho vidi ve fronte, vezme si ho a objevi se mu v Mych ticketech. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/25-navrh-pristupny-portal.md | 14 ++ documentation/99-zmeny.md | 65 +++++++ src/data/access.ts | 23 +++ web/src/components/dashboard/QuickAssign.tsx | 127 ++++++++++++ web/src/pages/dashboard/Tickets.tsx | 192 ++++++++++++++++--- web/src/types/dashboard.ts | 4 + 6 files changed, 394 insertions(+), 31 deletions(-) create mode 100644 web/src/components/dashboard/QuickAssign.tsx diff --git a/documentation/25-navrh-pristupny-portal.md b/documentation/25-navrh-pristupny-portal.md index e62ad0f..178ab82 100644 --- a/documentation/25-navrh-pristupny-portal.md +++ b/documentation/25-navrh-pristupny-portal.md @@ -287,6 +287,20 @@ prilezitost prestat posilat vsechno. ### Tvar +### Hledani zna zalozku, ve ktere stoji + +Seznam ticketu ma tri zalozky: Moje, Nezarazene a Vsechny (posledni jen pro +toho, kdo vidi i cizi tickety). Hledani do nich musi zapadnout, ne je obejit: + +- **z Mych hleda ve svych**, z Nezarazenych ve fronte, z Vsech v celem rozsahu + stropu. Modal to ma nahore napsat, ne to delat potichu. +- **volba \"hledat vsude\"** dava smysl jen tam, kde je zalozka Vsechny. Kdo + vidi jen svoje, nema kam rozsirit. +- vysledek se vraci **do te same zalozky**, ze ktere clovek hledal. + +Bez toho by hledani bylo ctvrta zalozka, ktera si zaklada vlastni pravidla - +a clovek by po zavreni nalezu nevedel, kde vlastne je. + **Modal je jen zadani.** Kriteria: - text a k nemu volba kde (predmet, obsah, externi ID, kontakt, vse), diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 82db638..5642099 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,71 @@ Nejnovejsi nahore. +## 2026-09-02 - Tickety maji zalozky, fronta umi prirazovat + +Seznam ticketu mel dva prepinace, "Moje tickety" a "Ve fronte", schovane mezi +ostatnimi filtry - splyvaly s nimi a vypadaly jako dalsi dva chipy. Jenze "co +mam u sebe", "co jeste nikdo nema" a "co je ve firme" nejsou filtry, jsou to +**tri ruzne prace a kazda chce jinou tabulku**. + +### Tri zalozky + +| Zalozka | Kdo ji ma | Cim se lisi | +| ---------------- | ----------------------------- | ------------------------------------ | +| Moje tickety | kdo je veden jako resitel | bez sloupce Resi, byl by tam porad on | +| Nezarazene | kazdy | radky s rychlym prirazenim | +| Vsechny tickety | kdo vidi i cizi tickety | sloupec Resi a filtr na sekce | + +**Sloupec Resi je jen ve Vsech.** V Mych by ve vsech radcich stalo tyz jmeno +a ve fronte je z definice prazdny. Presne o tom mluvil pozadavek: az v prehledu +vsech ma smysl videt, kdo co ma u sebe. + +Zalozka Vsechny se nenabizi tomu, kdo vidi jen svoje - byl by to tentyz seznam. +Rika to `Access.seesOthers`, a `Access.visibleGroups` k tomu prida sekce, ktere +smi filtrovat: kdo vidi celou firmu vsechny, vedouci jen ty, ktere vede. +Nabidnout mu sekci, ze ktere stejne nic neuvidi, je jen matouci. + +### Fronta umi prirazovat + +Nova `QuickAssign`: u kazdeho ticketu ve fronte tlacitko **Vzit si** a dve +roletky, komu a do ktere sekce. Prirazeni je ve fronte hlavni prace, ne jedna +z mnoha veci na detailu - otevirat kvuli tomu kazdy ticket znamena u dvaceti +ticketech ctyricet kliknuti navic. + +Dve roletky, ne jedna: clovek a sekce jsou dve ruzna rozhodnuti. "Tohle je pro +ucetni" jde rict bez toho, aby se resilo, kdo z nich ma dovolenou. + +Kdo nesmi rozdavat praci ostatnim, vidi jen "Vzit si". Endpointy uz existovaly +(`/claim`, `/assign`, `/group`), nova je jen cesta k nim. + +### Vychozi zalozka podle adresy + +Odkaz z widgetu nese `assignee`, takze `unassigned` otevre frontu a konkretni +resitel nebo sekce pohled Vsechny. Bez toho by clovek prisel z dlazdice "fronta +bez resitele" a koukal na svoje tickety. + +Kdo neni veden jako resitel, nema zalozku Moje. Vychozi stav ji ale predpoklada, +protoze prava dorazi az po prvnim vykresleni - proto pojistka, ktera po nacteni +prav prepne na prvni dostupnou zalozku. + +### Hledani s tim pocita + +Zapsano do navrhu: hledani ma zapadnout do zalozky, ve ktere clovek stoji, ne ji +obejit. Z Mych hleda ve svych, z Nezarazenych ve fronte, z Vsech v celem rozsahu +stropu, a vysledek se vraci do te same zalozky. Volba "hledat vsude" dava smysl +jen tam, kde zalozka Vsechny vubec je. + +### Overeno na bezici instanci + +| Kdo | Zalozka Vsechny | Sekce ve filtru | +| ------------------------- | --------------- | --------------- | +| spravce platformy | ano | vsechny | +| Vomacka, vede Servicedesk | ano | jen Servicedesk | +| Kriz, radovy clen | ne | zadne | + +A cely pohyb ticketu frontou: admin preda TK-4819 do sekce, Kriz ho vidi ve +fronte, vezme si ho a objevi se mu v Mych ticketech. + ## 2026-09-02 - Nezarazene vidi kazdy, a dlazdice "Moje tickety" Dve veci, ktere ze stropu viditelnosti vypadly. diff --git a/src/data/access.ts b/src/data/access.ts index 2d3ee9a..8751e7a 100644 --- a/src/data/access.ts +++ b/src/data/access.ts @@ -102,6 +102,20 @@ export interface Access { nav: NavItem[]; /** true = vidi napric firmami a smi platformni nastaveni. */ platformAdmin: boolean; + /** + * Vidi i cizi tickety, ne jen svoje a frontu? + * + * Podle toho se nabizi zalozka "Vsechny tickety". Kdo vidi jen svoje, + * nema co prepinat - zalozka by mu ukazala totez co ta prvni. + */ + seesOthers: boolean; + /** + * Sekce, na ktere clovek vidi. Filtr v zalozce "Vsechny tickety". + * + * Kdo vidi celou firmu, dostane vsechny. Vedouci jen ty, ktere vede - + * nabidnout mu sekci, ze ktere stejne nic neuvidi, je jen matouci. + */ + visibleGroups: Array<{ id: string; name: string }>; } /** @@ -133,6 +147,13 @@ export function accessFor(user: User, tenantId?: string | null): Access { const defaultTenantId = tenants[0]?.id ?? null; const activeTenant = tenantId ?? defaultTenantId; + const visibility = visibilityFor(user, activeTenant); + const groups = activeTenant ? listGroups([activeTenant]) : []; + const visibleGroups = + visibility.kind === 'all' + ? groups + : groups.filter((group) => visibility.groupIds.includes(group.id)); + return { scopes, tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })), @@ -145,6 +166,8 @@ export function accessFor(user: User, tenantId?: string | null): Access { permissions: [...permissionsOf(user, activeTenant)].sort(), nav: navFor(user, activeTenant), platformAdmin: user.platformAdmin, + seesOthers: visibility.kind === 'all' || visibility.groupIds.length > 0, + visibleGroups: visibleGroups.map((group) => ({ id: group.id, name: group.name })), }; } diff --git a/web/src/components/dashboard/QuickAssign.tsx b/web/src/components/dashboard/QuickAssign.tsx new file mode 100644 index 0000000..d9012a0 --- /dev/null +++ b/web/src/components/dashboard/QuickAssign.tsx @@ -0,0 +1,127 @@ +/** + * Rychle prirazeni ticketu z fronty. + * + * Fronta nezarazenych je jedine misto, kde je prirazeni **hlavni prace**, ne + * jedna z mnoha veci na detailu. Otevirat kvuli tomu ticket a hledat v nem + * roletku znamena u dvaceti ticketu ctyricet kliknuti navic. + * + * Dve roletky, ne jedna: clovek a sekce jsou dve ruzna rozhodnuti. "Tohle je + * pro ucetni" jde rict bez toho, aby se resilo, kdo z nich ma dovolenou. + */ + +import { useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/cn'; + +const selectClass = + 'rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none disabled:opacity-50'; + +export function QuickAssign({ + ticketId, + people, + groups, + canAssignOthers, + meId, + onDone, +}: { + ticketId: string; + people: Array<{ id: string; name: string }>; + groups: Array<{ id: string; name: string }>; + /** false = clovek smi ticket vzit jen na sebe, ne ho rozdavat. */ + canAssignOthers: boolean; + meId: string | null; + onDone: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function send(path: string, body: unknown) { + setBusy(true); + setError(null); + try { + await apiFetch(`/api/dashboard/tickets/${ticketId}${path}`, { method: 'POST', body }); + onDone(); + } catch (err) { + console.error(`[fronta] ${path} selhalo:`, err); + setError(err instanceof Error ? err.message : 'Nepodařilo se uložit.'); + } finally { + setBusy(false); + } + } + + return ( +
+ {/* + Vzit na sebe je nejcastejsi pohyb ve fronte, proto tlacitko a ne + polozka v roletce. Ma ho i ten, kdo nesmi rozdavat praci ostatnim. + */} + {meId && ( + + )} + + {canAssignOthers && people.length > 0 && ( + <> + + + + )} + + {groups.length > 0 && ( + <> + + + + )} + + {error && {error}} +
+ ); +} diff --git a/web/src/pages/dashboard/Tickets.tsx b/web/src/pages/dashboard/Tickets.tsx index 03c5ee1..6d69807 100644 --- a/web/src/pages/dashboard/Tickets.tsx +++ b/web/src/pages/dashboard/Tickets.tsx @@ -1,5 +1,5 @@ import { Plus, Search } from 'lucide-react'; -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { DataState } from '@/components/dashboard/DataState'; @@ -9,6 +9,7 @@ import { TicketStatusBadge, } from '@/components/dashboard/StatusBadge'; import { NewTicketDialog } from '@/components/dashboard/NewTicketDialog'; +import { QuickAssign } from '@/components/dashboard/QuickAssign'; import { TicketWorkload } from '@/components/dashboard/TicketWorkload'; import { TicketTable } from '@/components/dashboard/TicketTable'; import { ViewSwitch } from '@/components/dashboard/ViewSwitch'; @@ -20,6 +21,7 @@ import { useApiQuery } from '@/lib/useApiQuery'; import { usePageMeta } from '@/lib/usePageMeta'; import type { Access, + PeopleResponse, TicketChannel, TicketListResponse, TicketScope, @@ -62,7 +64,23 @@ export default function Tickets() { groupId: params.get('groupId'), }).current; - const [scope, setScope] = useState(initial.tenantId ? 'tenant' : null); + /* + * Vychozi zalozka podle adresy. + * + * Odkaz z widgetu nese `assignee`, takze `unassigned` znamena frontu + * a konkretni resitel pohled na vsechny. Bez toho by clovek prisel + * z dlazdice "fronta bez resitele" a koukal na svoje tickety. + */ + const initialTab: 'moje' | 'fronta' | 'vsechny' = + initial.assignee === 'unassigned' + ? 'fronta' + : initial.assignee !== null || initial.groupId !== null + ? 'vsechny' + : 'moje'; + + const [scope, setScope] = useState( + initialTab === 'moje' ? 'mine' : initial.tenantId ? 'tenant' : null, + ); const [tenantId, setTenantId] = useState(initial.tenantId); /** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */ const [assignee, setAssignee] = useState(initial.assignee); @@ -78,7 +96,16 @@ export default function Tickets() { const [view, setView] = useState<'table' | 'grid'>('table'); const [creating, setCreating] = useState(false); /** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */ - const [mine, setMine] = useState(false); + const [mine, setMine] = useState(initialTab === 'moje'); + + /* + * Tri zalozky misto prepinacu. + * + * Puvodne to byly dva chipy vedle ostatnich filtru a splyvaly s nimi. + * Jenze "co mam u sebe", "co jeste nikdo nema" a "co je ve firme" nejsou + * filtry, jsou to tri ruzne prace - a kazda chce jinou tabulku. + */ + const [tab, setTab] = useState(initialTab); // Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla. const path = useMemo(() => { @@ -107,6 +134,10 @@ export default function Tickets() { const tickets = useApiQuery(path, { refetchOn: [...ticketEvents] }); const workload = useApiQuery(workloadPath, { refetchOn: [...ticketEvents] }); + // Do fronty: komu se da ticket predat. Nacita se jen pro zalozku Nezarazene. + const people = useApiQuery('/api/dashboard/people', { + enabled: tab === 'fronta', + }); // Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi. const items = useMemo(() => { @@ -122,16 +153,54 @@ export default function Tickets() { ); }, [tickets.data, query]); + /** Prepnuti zalozky. Jedno misto, kde se meni pohled i filtr na resitele. */ + function openTab(next: 'moje' | 'fronta' | 'vsechny') { + setTab(next); + setGroupId(null); + if (next === 'moje') { + setMine(true); + setScope('mine'); + setAssignee(null); + } else { + setMine(false); + setScope(scope === 'all' ? 'all' : 'tenant'); + setAssignee(next === 'fronta' ? 'unassigned' : null); + } + } + const data = tickets.data; const meId = data?.meId ?? null; const filtered = assignee !== null || status !== null || channel !== null; const canCreate = access.data?.permissions.includes('ticket.create') === true; + const canAssignOthers = access.data?.canAssignOthers === true; + /** Sekce, na ktere clovek vidi. Filtr v zalozce Vsechny. */ + const visibleGroups = access.data?.visibleGroups ?? []; + const allowedScopes = access.data?.scopes ?? []; const tenants = access.data?.tenants ?? []; // Dokud server neodpovi, drzime se toho, co poslal u seznamu. const activeScope = scope ?? data?.scope ?? null; + const ticketTabs = ([ + { key: 'moje', label: 'Moje tickety', show: allowedScopes.includes('mine') }, + { key: 'fronta', label: 'Nezařazené', show: true }, + // Kdo vidi jen svoje, nema co prepinat: byl by to tentyz seznam. + { key: 'vsechny', label: 'Všechny tickety', show: access.data?.seesOthers === true }, + ] as const).filter((item) => item.show); + + /* + * Kdo neni veden jako resitel, nema zalozku Moje. Vychozi stav ji ale + * predpoklada, protoze prava dorazi az po prvnim vykresleni - bez teto + * pojistky by koukal na prazdno a zadna zalozka by nesvitila. + */ + useEffect(() => { + if (!access.data) return; + if (ticketTabs.some((item) => item.key === tab)) return; + openTab(ticketTabs[0]?.key ?? 'fronta'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [access.data]); + return (
@@ -165,6 +234,28 @@ export default function Tickets() {
+ {/* + Zalozky, ne filtry. Kdo vidi jen svoje, nema co prepinat na + "vsechny" - videl by tam totez a jen by ho to matlo. + */} +
+ {ticketTabs.map((item) => ( + + ))} +
+
@@ -213,36 +304,29 @@ export default function Tickets() { )} - {allowedScopes.includes('mine') && ( - { - const next = !mine; - setMine(next); - setScope(next ? 'mine' : activeScope === 'all' ? 'all' : 'tenant'); - if (next) setAssignee(null); - }} - title="Jen tickety, které mám u sebe." + {/* + Sekce jen v zalozce Vsechny. V Mych je vzdycky moje a ve + fronte jeste zadna neni - ticket bez resitele casto nema + ani skupinu, prave proto v te fronte lezi. + */} + {tab === 'vsechny' && visibleGroups.length > 0 && ( + )} - { - setAssignee(assignee === 'unassigned' ? null : 'unassigned'); - // Fronta a moje se vylucuji, ticket ve fronte nema resitele. - if (assignee !== 'unassigned' && mine) { - setMine(false); - setScope('tenant'); - } - }} - title="Tickety, které nemá nikdo u sebe." - > - Ve frontě - - {/* Filtry z odkazu. V UI k nim tlacitko neni, ale musi jit zrusit - jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane. @@ -371,10 +455,56 @@ export default function Tickets() { ))}
+ ) : tab === 'fronta' ? ( + /* + Fronta se kresli jinak nez seznam. Prirazeni je tady hlavni + prace, ne jedna z mnoha veci na detailu - otevirat kvuli + tomu kazdy ticket znamena u dvaceti tiketu ctyricet kliknuti + navic. + */ +
    + {items.map((ticket) => ( +
  • + + + {ticket.id} + + + + {formatRelative(ticket.updatedAt)} + + + + {ticket.subject} + + + + { + tickets.reload(); + workload.reload(); + }} + /> +
  • + ))} +
) : ( /* Tatáz tabulka jako na detailu osoby, vcetne chovani na mobilu. */
- + {/* + Sloupec Resi dava smysl jen tam, kde jsou tickety vic lidi. + V Mych by ve vsech radcich stalo moje jmeno. + */} +
)} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 1525401..23e5c9b 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -49,6 +49,10 @@ export interface Access { /** Zalozky ze serveru: prunik toho, co firma ma, a toho, na co ma pravo. */ nav: NavEntry[]; platformAdmin: boolean; + /** true = vidi i cizi tickety. Podle toho se nabizi zalozka Vsechny. */ + seesOthers: boolean; + /** Sekce, na ktere clovek vidi. Filtr v zalozce Vsechny. */ + visibleGroups: Array<{ id: string; name: string }>; } /** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */