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) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-02 12:48:15 +02:00
co-authored by Claude Opus 5
parent 52999a961b
commit e701bc0e2c
6 changed files with 394 additions and 31 deletions
@@ -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),
+65
View File
@@ -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.
+23
View File
@@ -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 })),
};
}
@@ -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<string | null>(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 (
<div className="flex flex-wrap items-center gap-2">
{/*
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 && (
<button
type="button"
disabled={busy}
onClick={() => void send('/claim', {})}
className={cn(
'rounded-full bg-brand-400 px-3 py-1.5 text-xs font-semibold text-ink-950',
'transition-colors hover:bg-brand-300 disabled:opacity-50',
)}
>
Vzít si
</button>
)}
{canAssignOthers && people.length > 0 && (
<>
<label htmlFor={`prirad-${ticketId}`} className="sr-only">
Přiřadit řešiteli
</label>
<select
id={`prirad-${ticketId}`}
value=""
disabled={busy}
onChange={(event) => {
if (event.target.value) void send('/assign', { assigneeId: event.target.value });
}}
className={selectClass}
>
<option value="" className="bg-ink-850">
Komu...
</option>
{people.map((person) => (
<option key={person.id} value={person.id} className="bg-ink-850">
{person.name}
</option>
))}
</select>
</>
)}
{groups.length > 0 && (
<>
<label htmlFor={`sekce-${ticketId}`} className="sr-only">
Předat sekci
</label>
<select
id={`sekce-${ticketId}`}
value=""
disabled={busy}
onChange={(event) => {
if (event.target.value) void send('/group', { groupId: event.target.value });
}}
className={selectClass}
>
<option value="" className="bg-ink-850">
Do sekce...
</option>
{groups.map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
</>
)}
{error && <span className="text-xs text-danger-400">{error}</span>}
</div>
);
}
+161 -31
View File
@@ -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<TicketScope | null>(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<TicketScope | null>(
initialTab === 'moje' ? 'mine' : initial.tenantId ? 'tenant' : null,
);
const [tenantId, setTenantId] = useState<string | null>(initial.tenantId);
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
const [assignee, setAssignee] = useState<string | null>(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<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
const workload = useApiQuery<Workload>(workloadPath, { refetchOn: [...ticketEvents] });
// Do fronty: komu se da ticket predat. Nacita se jen pro zalozku Nezarazene.
const people = useApiQuery<PeopleResponse>('/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 (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
@@ -165,6 +234,28 @@ export default function Tickets() {
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<div className="space-y-4">
{/*
Zalozky, ne filtry. Kdo vidi jen svoje, nema co prepinat na
"vsechny" - videl by tam totez a jen by ho to matlo.
*/}
<div className="flex flex-wrap gap-2">
{ticketTabs.map((item) => (
<button
key={item.key}
type="button"
onClick={() => openTab(item.key)}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
tab === item.key
? 'bg-brand-500/15 text-brand-100'
: 'text-white/50 hover:text-white',
)}
>
{item.label}
</button>
))}
</div>
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
@@ -213,36 +304,29 @@ export default function Tickets() {
</select>
)}
{allowedScopes.includes('mine') && (
<Chip
active={mine}
onClick={() => {
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 && (
<select
value={groupId ?? ''}
onChange={(event) => setGroupId(event.target.value || null)}
aria-label="Sekce"
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
>
Moje tickety
</Chip>
<option value="" className="bg-ink-850">
Všechny sekce
</option>
{visibleGroups.map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
)}
<Chip
active={assignee === 'unassigned'}
onClick={() => {
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ě
</Chip>
{/*
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() {
</Link>
))}
</div>
) : 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.
*/
<ul className="divide-y divide-ink-600/40">
{items.map((ticket) => (
<li key={ticket.id} className="flex flex-wrap items-center gap-3 p-3 sm:p-4">
<Link to={`/dashboard/tickety/${ticket.id}`} className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
<TicketPriorityBadge priority={ticket.priority} />
<span className="text-xs text-white/35">
{formatRelative(ticket.updatedAt)}
</span>
</span>
<span className="mt-1 block truncate text-sm text-white/85">
{ticket.subject}
</span>
</Link>
<QuickAssign
ticketId={ticket.id}
people={people.data?.items ?? []}
groups={people.data?.groups ?? []}
canAssignOthers={canAssignOthers}
meId={meId}
onDone={() => {
tickets.reload();
workload.reload();
}}
/>
</li>
))}
</ul>
) : (
/* Tatáz tabulka jako na detailu osoby, vcetne chovani na mobilu. */
<div className="p-2 sm:p-3">
<TicketTable tickets={items} meId={meId} />
{/*
Sloupec Resi dava smysl jen tam, kde jsou tickety vic lidi.
V Mych by ve vsech radcich stalo moje jmeno.
*/}
<TicketTable
tickets={items}
meId={meId}
hide={tab === 'vsechny' ? undefined : ['assignee']}
/>
</div>
)}
</DataState>
+4
View File
@@ -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. */