From 6bcd7f845cd37109c2289754e01fe3775da2bc0d Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:15:47 +0200 Subject: [PATCH] Hierarchie firmy: kdo co vidi, a zalozka Firma Pohled na celou firmu dostal kazdy, kdo do ni patril: scopes.push('tenant') se v access.ts neptalo na nic. mine byl dobrovolny filtr, ne strop, takze resitel s roli agent poslal ?scope=tenant a dostal cely provoz. Rozdil, na kterem to ted stoji: pohled je co chci videt, strop je co vubec smim videt. Existoval jen pohled a klientovi se veril. Model: - Membership.seesAllTenant - vidi cely provoz firmy. Postaveni uctu ve firme, ne vlastnost resitele: clovek muze byt ve dvou firmach jednou reditel a jednou brigadnik - PersonGroup.members je { personId, seesAll } misto holeho personIds. seesAll je vedouci sekce. Priznak visi na clenstvi, ne na cloveku a ne na skupine - diky tomu muze byt clovek v peti sekcich a jen ve dvou videt vsechno, coz role rict neumi, ta je jedna na celou firmu - stara podoba personIds se dal cte, prevadi ji groupMembers Vypocet stropu (visibilityFor), sjednoceni ne prunik: spravce platformy nebo seesAllTenant vidi celou firmu, jinak svoje tickety plus vse ze sekci, kde ma zaskrtnuto, plus jejich fronta. U zaznamu bez priznaku rozhoduje pravo ticket.assign.others - kdo smel prehazovat cizi praci, uz stejne cely provoz videl, takze se mu nic nebere. Vynuceni: - strop je povinna soucast TicketFilter, stejne jako tenantIds. Nepovinny filtr na prava je filtr, ktery jednou nekde chybi - takhle prekladac ukazal vsech trinact mist, ktera ho jeste nemela - getWorkload a getAgentStats uz nesahaji do pole ticketu primo, jdou pres listTickets. Driv obchazely kazde omezeni viditelnosti - getTicket kontroluje strop i u jednoho ticketu, bez toho by stacilo znat ID - detail a prevzeti pocitaji strop za firmu ticketu, ne za prave prepnutou Helpdesk se ridi toutez hierarchii, jen "moje" znamena, co jsem zalozil - zadavatel pozadavek nikdy nema prirazeny. Ticket proto nese createdById. Zalozka Firma: pod jednim mistem Prehled, Resitele, Sekce, Role a prava, Typy ticketu a Pozvanky. V Nastaveni zustal Muj ucet a platformni veci. Role a Typy jsou samostatne komponenty, sekce maji vlastni panel - u kazdeho clenstvi je prepinac a radek na cloveka obecny EntityAdmin neumi. Obsah ticketu se cte i v helpdesku, v seznamu jako jednoradkovy nahled. Overeno na bezici instanci se tremi ucty nad tymiz daty: spravce platformy vidi vsechny tri tickety vcetne neprirazeneho, Vomacka jako vedouci Servicedesku dva (tickety obou clenu sekce), Kriz jako radovy clen jeden, jen svuj. Adresa cizho ticketu vraci 404, vytizeni tymu ukazuje jen viditelne a po zaskrtnuti priznaku se rozsah zmeni hned. Co z toho plyne: neprirazeny ticket bez skupiny nevidi nikdo krome toho, kdo vidi celou firmu. Prichozi praci musi nekdo smerovat. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/07-firmy-a-prava.md | 47 +++ documentation/25-navrh-pristupny-portal.md | 5 +- documentation/99-zmeny.md | 93 ++++++ src/data/access.ts | 74 ++++- src/data/mock.ts | 3 +- src/data/people.ts | 51 +++- src/data/tenantFeatures.ts | 2 +- src/data/ticketStore.ts | 99 ++++++- src/routes/dashboard.ts | 55 +++- src/routes/helpdesk.ts | 16 +- src/routes/settings.ts | 18 +- src/routes/ticketActions.ts | 6 +- src/routes/widgetData.ts | 24 +- src/runtime/builtinSteps.ts | 14 +- src/types.ts | 12 + .../components/dashboard/DashboardLayout.tsx | 3 +- web/src/components/dashboard/GroupPanel.tsx | 277 ++++++++++++++++++ web/src/components/dashboard/RolesAdmin.tsx | 78 +++++ web/src/components/dashboard/TicketBody.tsx | 27 +- .../components/dashboard/TicketTypesAdmin.tsx | 72 +++++ web/src/pages/dashboard/Helpdesk.tsx | 9 +- web/src/pages/dashboard/People.tsx | 53 +--- web/src/pages/dashboard/Settings.tsx | 104 +------ web/src/pages/dashboard/TicketDetail.tsx | 4 +- 24 files changed, 951 insertions(+), 195 deletions(-) create mode 100644 web/src/components/dashboard/GroupPanel.tsx create mode 100644 web/src/components/dashboard/RolesAdmin.tsx create mode 100644 web/src/components/dashboard/TicketTypesAdmin.tsx diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index 9ada8db..4db56c7 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -206,3 +206,50 @@ podle toho, ktera firma je prave zvolena. | Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se | | Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje | | Audit pristupu | odepreni se jen loguje, nikde se neuklada | + +## Strop viditelnosti + +**Pohled je co chci videt, strop je co vubec smim videt.** Driv existoval jen +pohled: `scope=tenant` dostal kazdy, kdo do firmy patril, a `mine` byl +dobrovolny filtr. + +Strop pocita `visibilityFor` v `src/data/access.ts` a nese ho `ResolvedScope`, +protoze tudy prochazi kazdy dotaz na tickety: + +```text +spravce platformy nebo Membership.seesAllTenant -> cela firma +jinak -> moje tickety + + vse ze sekci, kde mam + u clenstvi zaskrtnuto + + fronta techhle sekci +``` + +Priznak visi na **clenstvi**, ne na cloveku a ne na skupine. Clovek muze byt +v peti sekcich a jen ve dvou z nich videt vsechno - to role rict neumi, ta je +jedna na celou firmu. + +`seesAllTenant === undefined` jsou zaznamy ulozene driv. Rozhoduje u nich +pravo `ticket.assign.others`: kdo smel prehazovat cizi praci, uz stejne cely +provoz videl. + +### Vynucuje se v ulozisti, ne v routach + +Strop je **povinna soucast `TicketFilter`**, stejne jako `tenantIds`. Nepovinny +filtr na prava je filtr, ktery jednou nekde chybi. `getWorkload` +a `getAgentStats` proto uz nesahaji do pole ticketu primo, jdou pres +`listTickets`, a `getTicket` ho kontroluje i u jednoho ticketu - bez toho by +stacilo znat ID. + +Detail a prevzeti pocitaji strop **za firmu ticketu**, ne za prave prepnutou: +odkaz z pohledu "vse" muze vest do jine firmy uzivatele. + +### Helpdesk + +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 + +Neprirazeny ticket **bez skupiny** nevidi nikdo krome toho, kdo vidi celou +firmu. Prichozi praci musi nekdo smerovat, jinak se k radovym clenum nedostane. diff --git a/documentation/25-navrh-pristupny-portal.md b/documentation/25-navrh-pristupny-portal.md index 2b03dae..1e76a69 100644 --- a/documentation/25-navrh-pristupny-portal.md +++ b/documentation/25-navrh-pristupny-portal.md @@ -1,7 +1,8 @@ # 25 - Navrh: pristupny portal a viditelnost -**Navrh, ne popis stavu.** Jedina hotova cast je sekce 6, ostatni zatim -naprogramovane neni. Az se cast udela, prepise se do prislusneho souboru +**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). diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 7c9862f..b1db969 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,99 @@ Nejnovejsi nahore. +## 2026-09-02 - Hierarchie firmy: kdo co vidi, a zalozka Firma + +Pohled na celou firmu dostal kazdy, kdo do ni patril: `scopes.push('tenant')` +se v `access.ts` neptalo na nic. `mine` byl dobrovolny filtr, ne strop, takze +resitel s roli `agent` poslal `?scope=tenant` a dostal cely provoz. + +Rozdil, na kterem to ted stoji: **pohled je co chci videt, strop je co vubec +smim videt.** Existoval jen pohled a klientovi se veril. + +### Model + +Priznak visi na **clenstvi**, ne na cloveku a ne na skupine. Diky tomu muze byt +clovek v peti sekcich a jen ve dvou z nich videt vsechno - to role rict neumi, +ta je jedna na celou firmu (`Membership` je dvojice firma a role). + +- `Membership.seesAllTenant` - vidi cely provoz firmy. Postaveni uctu ve firme, + ne vlastnost resitele: clovek muze byt ve dvou firmach jednou reditel + a jednou brigadnik. +- `PersonGroup.members` je `{ personId, seesAll }` misto holeho `personIds`. + `seesAll` je vedouci sekce. Vedoucich muze byt vic a jeden clovek muze vest + vic sekci, proto to neni zvlastni pole na skupine. +- Stara podoba `personIds` se dal cte, prevadi ji `groupMembers` - jedine misto, + kde se to deje, aby skupina ulozena driv neprisla o cleny. + +### Vypocet stropu + +`visibilityFor` v `data/access.ts`, sjednoceni ne prunik: + +```text +spravce platformy nebo seesAllTenant -> cela firma +jinak -> moje tickety + + vse ze sekci, kde mam zaskrtnuto + + fronta techhle sekci +``` + +`seesAllTenant === undefined` jsou zaznamy ulozene driv. Tam rozhoduje pravo +`ticket.assign.others`: kdo dosud smel prehazovat cizi praci, uz stejne cely +provoz videl, takze se mu nic nebere. Bezny resitel timhle sitem neprojde +a spadne na svoje sekce, coz je prave ta zmena, o kterou jde. + +### Kde se to vynucuje + +**Strop je povinna soucast `TicketFilter`**, stejne jako `tenantIds`. +Nepovinny filtr na prava je filtr, ktery jednou nekde chybi - takhle prekladac +ukaze kazde misto, ktere ho jeste nema. Pri zavedeni jich naslo trinact. + +- `listTickets` filtruje pres `withinVisibility` +- `getWorkload` a `getAgentStats` uz nesahaji do pole ticketu primo, jdou pres + `listTickets`. Driv obchazely kazde omezeni viditelnosti +- `getTicket` kontroluje strop i u jednoho ticketu. Bez toho by stacilo znat ID: + seznam by ho schoval, ale adresa detailu vydala +- detail a prevzeti pocitaji strop **za firmu ticketu**, ne za prave prepnutou - + odkaz z pohledu "vse" muze vest do jine firmy uzivatele + +### Helpdesk + +Ridi se toutez hierarchii, jen "moje" znamena neco jineho: zadavatel pozadavek +nikdy nema prirazeny, resi ho nekdo u dodavatele. Proto ma ticket nove +`createdById` a v helpdesku plati: kdo vidi celou firmu, vidi vsechny jeji +pozadavky, ostatni jen ty svoje. + +### Zalozka Firma + +Bylo to roztazene mezi dve mista. Nove je pod **Firma** vsechno, co se firmy +tyka: Prehled, Resitele, Sekce, Role a prava, Typy ticketu, Pozvanky. +V Nastaveni zustal Muj ucet a platformni veci, ktere klient nevidi. + +Role a Typy jsou ted samostatne komponenty (`RolesAdmin`, `TicketTypesAdmin`), +ne kus stranky nastaveni. + +Sekce maji vlastni panel misto obecneho `EntityAdmin`: u kazdeho clenstvi je +prepinac "vidi vse ze sekce" a radek na cloveka obecny editor s poli neumi. + +### Overeno na bezici instanci + +Tri ucty nad tymiz daty: + +| Kdo | Vidi | +| -------------------------- | --------------------------------------- | +| spravce platformy | vsechny 3 tickety vcetne neprirazeneho | +| Vomacka, vede Servicedesk | 2, tedy tickety obou clenu sekce | +| Kriz, radovy clen tehoz | 1, jen svuj | + +K tomu: adresa cizho ticketu vraci 404, vytizeni tymu ukazuje jen viditelne, +a po zaskrtnuti priznaku u clenstvi se rozsah zmeni hned. + +### Co z toho plyne + +Neprirazeny ticket **bez skupiny** nevidi nikdo krome toho, kdo vidi celou +firmu. Je to spravne podle modelu - takovy ticket nepatri do zadne sekce - ale +znamena to, ze prichozi praci musi nekdo smerovat, jinak radovym clenum nikdy +nedorazi. + ## 2026-09-02 - Obsah ticketu se na detailu cte `body` je jeden retezec, ale co v nem stoji, urcuje ten, kdo ho naplnil. Krok diff --git a/src/data/access.ts b/src/data/access.ts index 70a72a0..fa4a224 100644 --- a/src/data/access.ts +++ b/src/data/access.ts @@ -15,7 +15,7 @@ * pocitalo na dvou mistech a jednou se rozejde. */ -import { findPersonByEmail } from './people.js'; +import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js'; import { hasPermission, permissionsOf } from './permissions.js'; import { listActiveTenants, findTenant, type Tenant } from './tenants.js'; import { navFor, type NavItem } from './tenantFeatures.js'; @@ -23,6 +23,68 @@ import type { User } from '../types.js'; export type TicketScope = 'all' | 'tenant' | 'mine'; +/** + * Strop viditelnosti: co uzivatel **vubec smi** videt. + * + * Neplest s pohledem. Pohled (`TicketScope`) je to, co si clovek zvolil, strop + * je to, co mu vubec patri - a klient si ho nevolí. Driv existoval jen pohled, + * takze kazdy clen firmy si mohl vyzadat `scope=tenant` a dostal vsechno. + */ +export type Visibility = + | { kind: 'all' } + /** Jen tickety techhle lidi a techhle skupin, vcetne jejich fronty. */ + | { kind: 'scoped'; personIds: string[]; groupIds: string[] }; + +/** + * Co uzivatel smi videt ve vybrane firme. + * + * Sjednoceni, ne prunik: svoje tickety plus vsechno ze sekci, kde ma + * u clenstvi zaskrtnuto "vidi vse". + * + * `seesAllTenant === undefined` jsou zaznamy ulozene driv, kdy priznak + * neexistoval. Tam se pta na pravo prehazovat cizi praci: kdo dosud smel + * prirazovat ostatnim, uz stejne cely provoz videl, takze se mu nic nebere. + * Bezny resitel timhle sitem neprojde a spadne na svoje sekce - coz je prave + * ta zmena, o kterou jde. + */ +export function visibilityFor(user: User, tenantId: string | null): Visibility { + if (user.platformAdmin) return { kind: 'all' }; + if (!tenantId) return { kind: 'scoped', personIds: [], groupIds: [] }; + + const membership = user.memberships.find((item) => item.tenantId === tenantId); + if (membership?.seesAllTenant === true) return { kind: 'all' }; + if (membership?.seesAllTenant === undefined && hasPermission(user, 'ticket.assign.others', tenantId)) { + return { kind: 'all' }; + } + + const person = findPersonByEmail(user.email); + if (!person) return { kind: 'scoped', personIds: [], groupIds: [] }; + + const groupIds = groupsOfPerson(person.id, [tenantId]) + .filter((group) => groupMembers(group).some((m) => m.personId === person.id && m.seesAll)) + .map((group) => group.id); + + // Lide z vedenych sekci: jejich tickety patri vedoucimu do dohledu i tehdy, + // kdyz si je vzali na sebe a ze fronty skupiny tim zmizely. + const personIds = new Set([person.id]); + for (const group of listGroups([tenantId])) { + if (!groupIds.includes(group.id)) continue; + for (const member of groupMembers(group)) personIds.add(member.personId); + } + + return { kind: 'scoped', personIds: [...personIds], groupIds }; +} + +/** + * Vidi ten clovek na celou firmu? + * + * Kratka odpoved na `visibilityFor(...).kind === 'all'`. Pouziva to helpdesk, + * kde strop nejde uplatnit pres resitele a skupiny - zadavatel neni ani jedno. + */ +export function seesWholeTenant(user: User, tenantId: string | null): boolean { + return visibilityFor(user, tenantId).kind === 'all'; +} + export interface Access { /** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */ scopes: TicketScope[]; @@ -112,6 +174,13 @@ export interface ResolvedScope { tenantId: string | null; /** Vyplnene jen u pohledu `mine`. */ personId: string | null; + /** + * Strop viditelnosti. **Pohled se o nej orizne**, nikdy naopak. + * + * Nese ho `ResolvedScope`, protoze tudy prochazi kazdy dotaz na tickety. + * Kdyby si ho pocital az kazdy endpoint sam, jednou nekde chybi. + */ + visibility: Visibility; } export interface ScopeDenied { @@ -154,11 +223,13 @@ export function resolveScope( } if (scope === 'all') { + // Pohled pres vsechny firmy ma jen spravce platformy, strop je tedy 'all'. return { scope, tenantIds: access.tenants.map((tenant) => tenant.id), tenantId: null, personId: null, + visibility: { kind: 'all' }, }; } @@ -181,6 +252,7 @@ export function resolveScope( tenantIds: [tenantId], tenantId, personId: scope === 'mine' ? access.personId : null, + visibility: visibilityFor(user, tenantId), }; } diff --git a/src/data/mock.ts b/src/data/mock.ts index 7ff6092..69035a5 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -25,7 +25,8 @@ export function getRunsSeries(days = 14) { } export function getSummary(tenantIds: string[]) { - const tickets = listTickets({ tenantIds }); + // Ukazkova cisla nad vlastnimi daty, strop viditelnosti se sem nevztahuje. + const tickets = listTickets({ tenantIds, visibility: { kind: 'all' } }); const incidents = listIncidents(); const automations = listAutomations(tenantIds); const series = getRunsSeries(); diff --git a/src/data/people.ts b/src/data/people.ts index 2387527..00cf248 100644 --- a/src/data/people.ts +++ b/src/data/people.ts @@ -121,17 +121,58 @@ export function allPeople(): Person[] { * Clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich ma * dovolenou. Fronta bez resitele se tim rozpadne na fronty skupin. */ +/** + * Clenstvi ve skupine. + * + * Priznak visi na **clenstvi**, ne na cloveku a ne na skupine. Diky tomu muze + * byt clovek v peti skupinach a jen ve dvou z nich videt vsechno - to by role + * nikdy nedokazala rict, ta je jedna na celou firmu. + */ +export interface GroupMember { + personId: string; + /** + * true = vidi vsechny tickety skupiny, ne jen svoje. + * + * Tohle je "vedouci sekce". Neni to zvlastni pole na skupine schvalne: + * vedoucich muze byt vic a jeden clovek muze vest vic sekci. + */ + seesAll: boolean; +} + export interface PersonGroup extends TenantEntity { tenantId: string; name: string; - personIds: string[]; + members: GroupMember[]; + /** + * Stara podoba clenstvi, jen ID bez priznaku. + * + * Zaznamy ulozene driv ji maji misto `members`. Cte se pres `groupMembers`, + * zapisuje se uz vzdycky `members`. + */ + personIds?: string[]; +} + +/** + * Clenove skupiny, at uz je zaznam v jakekoliv podobe. + * + * Jedine misto, kde se stara podoba prevadi. Kdyby se cetlo `personIds` primo, + * skupina ulozena driv by po zmene modelu prisla o cleny. + */ +export function groupMembers(group: PersonGroup): GroupMember[] { + if (group.members) return group.members; + return (group.personIds ?? []).map((personId) => ({ personId, seesAll: false })); +} + +/** Je ten clovek clenem skupiny? */ +export function isMember(group: PersonGroup, personId: string): boolean { + return groupMembers(group).some((member) => member.personId === personId); } /** Skupiny, jejichz clenem ten clovek je. */ export function groupsOfPerson(personId: string, tenantIds: string[]): PersonGroup[] { return groupCache .all() - .filter((group) => tenantIds.includes(group.tenantId) && group.personIds.includes(personId)); + .filter((group) => tenantIds.includes(group.tenantId) && isMember(group, personId)); } export const groupStore = defineStore('personGroup'); @@ -144,7 +185,11 @@ export function seedGroups(): PersonGroup[] { id: 'grp_servicedesk', tenantId: 'tnt_automia', name: 'Servicedesk', - personIds: ['ppl_vomacka', 'ppl_kriz'], + // Vomacka sekci vede, takze vidi vsechno v ni. Kriz jen svoje. + members: [ + { personId: 'ppl_vomacka', seesAll: true }, + { personId: 'ppl_kriz', seesAll: false }, + ], createdAt: timestamp, updatedAt: timestamp, }, diff --git a/src/data/tenantFeatures.ts b/src/data/tenantFeatures.ts index 1dcd89d..1f63727 100644 --- a/src/data/tenantFeatures.ts +++ b/src/data/tenantFeatures.ts @@ -63,7 +63,7 @@ export const moduleCatalog: ModuleDefinition[] = [ * stejne rozhoduje pravo `helpdesk.view`, ktere prideluje admin firmy. */ { key: 'helpdesk', label: 'Helpdesk', permissions: ['helpdesk.view'], required: true }, - { key: 'people', label: 'Lidé', permissions: ['ticket.view'], required: true }, + { key: 'people', label: 'Firma', permissions: ['ticket.view'], required: true }, { key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] }, /* * Akce a widgety jsou **stejna uroven jako automatizace**, ne polozka diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index db9a9d0..1769136 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -19,6 +19,7 @@ import { config } from '../config.js'; import { publish } from '../events/bus.js'; import { currentRun } from '../runtime/context.js'; +import type { Visibility } from './access.js'; import { onTicketChanged } from './ticketHooks.js'; import { notify } from './notifications.js'; import { findPerson, type Person } from './people.js'; @@ -196,6 +197,13 @@ export interface Ticket { tags: string[]; /** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */ automationId: string | null; + /** + * Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace. + * + * Je to **ucet, ne resitel**: v helpdesku podle nej zadavatel pozna svoje + * pozadavky, a ten je nikdy nema prirazene - resi je nekdo u dodavatele. + */ + createdById: string | null; /** * Kdy se ticketu prvne nekdo venoval: prirazeni, komentar, zmena stavu. * Bez toho nejde rict, jak dlouho zakaznik cekal na reakci. @@ -245,6 +253,7 @@ interface StoredTicket | 'resolvedById' | 'reopenCount' | 'helpdeskSourceId' + | 'createdById' > { assigneeId: string | null; helpdeskSourceId?: string | null; @@ -259,6 +268,7 @@ interface StoredTicket resolvedAt?: string | null; resolvedById?: string | null; reopenCount?: number; + createdById?: string | null; events?: TicketEvent[]; } @@ -876,6 +886,7 @@ function toTicket(stored: StoredTicket): Ticket { resolvedById: stored.resolvedById ?? null, reopenCount: stored.reopenCount ?? 0, helpdeskSourceId: stored.helpdeskSourceId ?? null, + createdById: stored.createdById ?? null, }; if (!assigneeId) return { ...base, assignee: null }; @@ -897,6 +908,14 @@ export interface TicketFilter { * driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data. */ tenantIds: string[]; + /** + * Strop viditelnosti uvnitr firmy. **Povinny ze stejneho duvodu** jako + * `tenantIds`: nepovinny filtr na prava je filtr, ktery jednou nekde chybi. + * + * Poklada ho `resolveScope`, viz data/access.ts. Kdo ma videt vsechno, + * posila `{ kind: 'all' }` - vyslovne, ne vynechanim. + */ + visibility: Visibility; /** * Firmy, ze kterych pozadavek prisel pres helpdesk. * @@ -907,6 +926,11 @@ export interface TicketFilter { helpdeskSourceIds?: string[]; /** ID resitele, nebo 'unassigned' pro nepridelene. */ assignee?: string; + /** + * Ucet, ktery ticket zalozil. Pouziva **jen helpdesk**: tam je "moje" to, + * co jsem poslal, ne to, co mam prirazene. + */ + createdById?: string; status?: TicketStatus; channel?: TicketChannel; /** Typ ticketu. `none` = tickety bez typu. */ @@ -917,6 +941,20 @@ export interface TicketFilter { groupId?: string; } +/** + * Vejde se ticket do stropu? + * + * Sjednoceni, ne prunik: bud ho ma u sebe nekdo, na koho vidim, nebo lezi + * ve fronte sekce, kterou vedu. Fronta bez resitele patri do druhe podminky - + * bez ni by vedouci nemel co rozdelovat. + */ +function withinVisibility(ticket: StoredTicket, visibility: Visibility): boolean { + if (visibility.kind === 'all') return true; + if (ticket.assigneeId && visibility.personIds.includes(ticket.assigneeId)) return true; + if (ticket.assigneeGroupId && visibility.groupIds.includes(ticket.assigneeGroupId)) return true; + return false; +} + export function listTickets(filter: TicketFilter): Ticket[] { const sources = filter.helpdeskSourceIds; const selected = tickets.filter((ticket) => { @@ -925,6 +963,13 @@ export function listTickets(filter: TicketFilter): Ticket[] { if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false; } else if (!filter.tenantIds.includes(ticket.tenantId)) { return false; + } else if (!withinVisibility(ticket, filter.visibility)) { + /* + * Strop se pta jen u vlastnickeho pohledu. Pohled zadavatele nad nim + * neni: pozadavek posila firma dodavateli, zadavatel u nej neni resitel + * ani clen zadne skupiny, takze by mu strop vzal i to, co sam poslal. + */ + return false; } if (filter.status && ticket.status !== filter.status) return false; if (filter.channel && ticket.channel !== filter.channel) return false; @@ -942,6 +987,7 @@ export function listTickets(filter: TicketFilter): Ticket[] { ) { return false; } + if (filter.createdById && ticket.createdById !== filter.createdById) return false; if (filter.assignee === 'unassigned') return ticket.assigneeId === null; if (filter.assignee && ticket.assigneeId !== filter.assignee) return false; return true; @@ -969,6 +1015,7 @@ export function getTicket( id: string, tenantIds: string[], helpdeskSourceIds: string[] = [], + visibility: Visibility = { kind: 'all' }, ): TicketDetail | undefined { const stored = tickets.find((t) => t.id === id); if (!stored) return undefined; @@ -983,6 +1030,18 @@ export function getTicket( console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`); return undefined; } + + /* + * Strop plati i na jeden ticket, ne jen na seznam. Bez toho by staciloa + * znat ID: seznam by ho neukazal, ale adresa detailu by ho vydala. + * + * Pohledu zadavatele se to netyka, ten stoji na `helpdeskSourceIds` - viz + * `listTickets`. + */ + if (owns && !asked && !withinVisibility(stored, visibility)) { + console.warn(`[tickets] pokus o cteni ticketu ${id} mimo strop viditelnosti`); + return undefined; + } return { ...toTicket(stored), trace: traces.get(id) ?? [], @@ -990,6 +1049,18 @@ export function getTicket( }; } +/** + * Vejde se ten ticket do stropu? + * + * Pouziva detail a prevzeti, kde se firma ticketu pozna az po nalezeni - + * odkaz z pohledu "vse" muze vest do jine firmy uzivatele a strop se pocita + * za firmu ticketu, ne za prave prepnutou. + */ +export function ticketWithinVisibility(id: string, visibility: Visibility): boolean { + const stored = tickets.find((t) => t.id === id); + return stored ? withinVisibility(stored, visibility) : false; +} + /** Prvni nevyrizeny ticket. */ export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { const stored = tickets.find( @@ -1021,11 +1092,17 @@ export interface Workload { openTotal: number; } -export function getWorkload(everyone: Person[], tenantIds: string[]): Workload { - const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId)); +export function getWorkload( + everyone: Person[], + tenantIds: string[], + visibility: Visibility, +): Workload { + // Pres listTickets, aby strop platil i tady. Driv se sahalo primo do pole + // a vytizeni tymu tak obchazelo kazde omezeni viditelnosti. + const visible = listTickets({ tenantIds, visibility }); const rows = everyone.map((person) => { - const mine = visible.filter((t) => t.assigneeId === person.id); + const mine = visible.filter((t) => t.assignee?.id === person.id); const open = mine.filter((t) => !t.closed); const oldest = open.reduce( (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), @@ -1047,9 +1124,7 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload { return { rows, - unassigned: visible.filter( - (t) => t.assigneeId === null && !t.closed, - ).length, + unassigned: visible.filter((t) => t.assignee === null && !t.closed).length, openTotal: visible.filter((t) => !t.closed).length, }; } @@ -1453,13 +1528,12 @@ export function getAgentStats( everyone: Person[], tenantIds: string[], since: number | null, + visibility: Visibility, ): AgentStatsRow[] { - const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId)); + const visible = listTickets({ tenantIds, visibility }); const rows = everyone.map((person) => { - const open = visible.filter( - (t) => t.assigneeId === person.id && !t.closed, - ); + const open = visible.filter((t) => t.assignee?.id === person.id && !t.closed); /** * Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv, @@ -1483,7 +1557,7 @@ export function getAgentStats( .filter((value) => value >= 0); const responseTimes = visible - .filter((t) => t.assigneeId === person.id && at(t.firstResponseAt) !== null) + .filter((t) => t.assignee?.id === person.id && at(t.firstResponseAt) !== null) .map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0)) .filter((value) => value >= 0); @@ -1541,6 +1615,8 @@ export interface CreateTicketInput { fields?: Record; tags?: string[]; automationId?: string | null; + /** Ucet, ktery ho zaklada rucne. null u automatizace a prijmu zvenku. */ + createdById?: string | null; /** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */ trace?: TraceInput[]; } @@ -1583,6 +1659,7 @@ export function createTicket(input: CreateTicketInput): Ticket { tags: input.tags ?? [], closed: input.closed ?? false, automationId: input.automationId ?? null, + createdById: input.createdById ?? null, createdAt: now, updatedAt: now, }; diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 22dd327..6061634 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -5,6 +5,7 @@ import { accessFor, isDenied, resolveScope, + visibilityFor, type ResolvedScope, } from '../data/access.js'; import { @@ -43,7 +44,13 @@ import { collectScopes } from '../data/flowScope.js'; import { widgetCatalog } from '../data/widgets.js'; import { createIncident, listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; -import { findPersonByEmail, groupsOfPerson, listGroups, listPeople } from '../data/people.js'; +import { + findPersonByEmail, + groupsOfPerson, + isMember, + listGroups, + listPeople, +} from '../data/people.js'; import { hasPermission } from '../data/permissions.js'; import { listNotifications, markRead, unreadCount } from '../data/notifications.js'; import { inviteRouter } from './invites.js'; @@ -59,6 +66,7 @@ import { ticketAssignee, getAgentStats, getTicket, + ticketWithinVisibility, getWorkload, listTickets, updateTicketStatus, @@ -355,20 +363,27 @@ dashboardRouter.get('/people/:id', (req, res) => { return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' }); } - const tickets = listTickets({ tenantIds: scope.tenantIds, assignee: person.id }); - const resolvedRecently = listTickets({ tenantIds: scope.tenantIds }).filter( + const tickets = listTickets({ + tenantIds: scope.tenantIds, + visibility: scope.visibility, + assignee: person.id, + }); + const resolvedRecently = listTickets({ + tenantIds: scope.tenantIds, + visibility: scope.visibility, + }).filter( (ticket) => ticket.resolvedById === person.id, ); // Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech. const since = Date.now() - 30 * 86_400_000; - const stats = getAgentStats([person], scope.tenantIds, since)[0] ?? null; + const stats = getAgentStats([person], scope.tenantIds, since, scope.visibility)[0] ?? null; return res.json({ person, stats, groups: listGroups(scope.tenantIds) - .filter((group) => group.personIds.includes(person.id)) + .filter((group) => isMember(group, person.id)) .map((group) => ({ id: group.id, name: group.name })), /** Co ma prave ted u sebe. */ open: tickets.filter((ticket) => !ticket.closed), @@ -502,8 +517,11 @@ function myOpenTickets(req: Request): number { const access = accessFor(req.user!); if (!access.personId) return 0; + // Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr + // na prava nesmi byt nepovinny. return listTickets({ tenantIds: access.tenants.map((tenant) => tenant.id), + visibility: { kind: 'scoped', personIds: [access.personId], groupIds: [] }, assignee: access.personId, }).filter((ticket) => !ticket.closed).length; } @@ -579,7 +597,7 @@ const ticketChannels: TicketChannel[] = [ * je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni. */ function ticketFilterFrom(query: Record, scope: ResolvedScope): TicketFilter { - const filter: TicketFilter = { tenantIds: scope.tenantIds }; + const filter: TicketFilter = { tenantIds: scope.tenantIds, visibility: scope.visibility }; // Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho". const typeId = query.typeId; @@ -637,7 +655,11 @@ dashboardRouter.get('/tickets', (req, res) => { * kliknuti na jeden stav zmizely ostatni a nesel by prepnout. */ const statuses = [ - ...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)), + ...new Set( + listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map( + (ticket) => ticket.status, + ), + ), ].sort((a, b) => a.localeCompare(b, 'cs')); return res.json({ @@ -784,6 +806,11 @@ dashboardRouter.post('/tickets/:id/claim', (req, res) => { const ticket = getTicket(req.params.id, tenantIds); if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + // Prevzit jde jen to, na co clovek vidi. + if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) { + return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + } + if (current !== null && current !== access.personId) { // Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti. // Pravo se pta za firmu ticketu, ne za tu prave prepnutou. @@ -838,7 +865,11 @@ dashboardRouter.get('/tickets/statuses', (req, res) => { if (!scope) return; const items = [ - ...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)), + ...new Set( + listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map( + (ticket) => ticket.status, + ), + ), ].sort((a, b) => a.localeCompare(b, 'cs')); return res.json({ items }); @@ -847,7 +878,7 @@ dashboardRouter.get('/tickets/statuses', (req, res) => { dashboardRouter.get('/tickets/workload', (req, res) => { const scope = scopeOrDeny(req, res); if (!scope) return; - return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds)); + return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility)); }); dashboardRouter.get('/tickets/:id', (req, res) => { @@ -861,6 +892,12 @@ dashboardRouter.get('/tickets/:id', (req, res) => { if (!ticket) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); } + + // Strop se pocita za firmu ticketu, ne za prave prepnutou. Bez teto kontroly + // by seznam ticket schoval, ale adresa detailu by ho vydala. + if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) { + return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + } return res.json(ticket); }); diff --git a/src/routes/helpdesk.ts b/src/routes/helpdesk.ts index 87bdfe8..7f3c0ad 100644 --- a/src/routes/helpdesk.ts +++ b/src/routes/helpdesk.ts @@ -25,7 +25,7 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; -import { actingTenant } from '../data/access.js'; +import { actingTenant, seesWholeTenant } from '../data/access.js'; import { recordAudit } from '../data/audit.js'; import { hasPermission } from '../data/permissions.js'; import { findPersonByEmail } from '../data/people.js'; @@ -112,7 +112,17 @@ helpdeskRouter.get('/', (req, res) => { const provider = tenant?.helpdeskProviderId ?? null; return res.json({ - items: listTickets({ tenantIds: [], helpdeskSourceIds: [tenantId] }), + /* + * Kdo z firmy vidi ktere pozadavky, se ridi toutez hierarchii jako + * u ticketu. Lisi se jen to, co je moje: zadavatel pozadavek nikdy nema + * prirazeny, resi ho nekdo u dodavatele - takze je to to, co sam zalozil. + */ + items: listTickets({ + tenantIds: [], + helpdeskSourceIds: [tenantId], + visibility: { kind: 'all' }, + ...(seesWholeTenant(req.user!, tenantId) ? {} : { createdById: req.user!.id }), + }), tenantId, /** Komu se posila. Klient podle toho rekne, na koho se pozadavek obraci. */ provider: provider ? { id: provider, name: findTenant(provider)?.name ?? provider } : null, @@ -154,6 +164,8 @@ helpdeskRouter.post('/', (req, res) => { // Vlastnikem je dodavatel, aby to mel ve sve fronte jako kazdy jiny ticket. tenantId: provider, helpdeskSourceId: tenantId, + // Podle tohohle zadavatel pozna svoje pozadavky, viz seznam vyse. + createdById: req.user!.id, subject: parsed.data.subject, body: parsed.data.body, channel: 'portal', diff --git a/src/routes/settings.ts b/src/routes/settings.ts index b3439c0..8b31b8a 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -325,9 +325,21 @@ settingsRouter.get('/people-overview', (req, res) => { // ------------------------------------------------------------------- skupiny +/** + * Clen skupiny. + * + * `seesAll` je "vedouci sekce": vidi vsechny tickety skupiny, ne jen svoje. + * Visi to na **clenstvi**, ne na cloveku a ne na skupine, takze jeden clovek + * muze jednu sekci vest a v druhe byt radovy. + */ +const groupMember = z.object({ + personId: z.string().min(1), + seesAll: z.boolean().default(false), +}); + const groupCreate = z.object({ name: z.string().trim().min(2).max(60), - personIds: z.array(z.string()).default([]), + members: z.array(groupMember).default([]), }); settingsRouter.use( @@ -338,12 +350,12 @@ settingsRouter.use( createSchema: groupCreate, updateSchema: z.object({ name: z.string().trim().min(2).max(60).optional(), - personIds: z.array(z.string()).optional(), + members: z.array(groupMember).optional(), }), writePermission: 'group.manage', build: (input: z.infer) => ({ name: input.name, - personIds: input.personIds, + members: input.members, }), }), ); diff --git a/src/routes/ticketActions.ts b/src/routes/ticketActions.ts index f68dddf..8ad525f 100644 --- a/src/routes/ticketActions.ts +++ b/src/routes/ticketActions.ts @@ -111,7 +111,7 @@ ticketActionsRouter.get('/:id/actions', async (req, res) => { return res.status(scope.status).json({ error: scope.error, message: scope.message }); } - const ticket = getTicket(req.params.id, scope.tenantIds); + const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility); if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); const actions = actionsForTicket( @@ -143,7 +143,7 @@ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => { return res.status(scope.status).json({ error: scope.error, message: scope.message }); } - const ticket = getTicket(req.params.id, scope.tenantIds); + const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility); if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); const action = findAction(req.params.actionId); @@ -362,7 +362,7 @@ function builtinAction(options: { return res.status(scope.status).json({ error: scope.error, message: scope.message }); } - const ticket = getTicket(req.params.id, scope.tenantIds); + const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility); if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); if (!hasPermission(req.user!, options.permission, ticket.tenantId)) { diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index dfbbc74..cb8ce7b 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -394,12 +394,17 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue if (source.kind === 'agentStats') { return { kind: 'agents', - rows: getAgentStats(listPeople(scope.tenantIds), scope.tenantIds, since(source.period)), + rows: getAgentStats( + listPeople(scope.tenantIds), + scope.tenantIds, + since(source.period), + scope.visibility, + ), }; } if (source.kind === 'workload') { - const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds); + const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility); return { kind: 'workload', rows: workload.rows.map((row) => ({ @@ -420,9 +425,10 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue throw new Error('Zdroj Konektor se počítá zvlášť.'); } - const tickets = listTickets({ tenantIds: scope.tenantIds }).filter((ticket) => - matches(ticket, source.filter, scope.personId), - ); + const tickets = listTickets({ + tenantIds: scope.tenantIds, + visibility: scope.visibility, + }).filter((ticket) => matches(ticket, source.filter, scope.personId)); if (source.kind === 'ticketCount') { if (!source.groupBy) { @@ -561,6 +567,12 @@ widgetDataRouter.get('/options', (req, res) => { statuses: Object.entries(statusLabels).map(([value, label]) => ({ value, label })), channels: Object.entries(channelLabels).map(([value, label]) => ({ value, label })), // Tagy se berou z toho, co je na ticketech - vlastni seznam tagu neexistuje. - tags: [...new Set(listTickets({ tenantIds: scope.tenantIds }).flatMap((ticket) => ticket.tags))].sort(), + tags: [ + ...new Set( + listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).flatMap( + (ticket) => ticket.tags, + ), + ), + ].sort(), }); }); diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index e0288cb..0617901 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -20,7 +20,13 @@ import { sendMail } from '../mail/smtp.js'; import { resolveTarget } from '../scripts/connections.js'; import { findTenantScript, noteRun } from '../data/tenantScripts.js'; import { runSandbox } from './sandbox.js'; -import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js'; +import { + findGroup, + findPerson, + findPersonByExternalId, + isMember, + listPeople, +} from '../data/people.js'; import { assignTicket, assignTicketGroup, @@ -429,13 +435,15 @@ const handlers: Record = { } const people = listPeople([context.tenantId]).filter( - (person) => group.personIds.includes(person.id) && person.enabled !== false, + (person) => isMember(group, person.id) && person.enabled !== false, ); if (people.length === 0) { return { ok: false, summary: `skupina ${group.name} nemá koho`, outputs: {} }; } - const workload = getWorkload(people, [context.tenantId]); + // Automat rozdeluje praci za firmu, ne za prihlaseneho cloveka - strop + // viditelnosti se sem nevztahuje. + const workload = getWorkload(people, [context.tenantId], { kind: 'all' }); const best = [...workload.rows].sort((a, b) => { if (a.open !== b.open) return a.open - b.open; // Pri shode rozhoduje podil ke kapacite: dva tickety u cloveka s kapacitou diff --git a/src/types.ts b/src/types.ts index ffd476d..f5fd194 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,18 @@ export interface Membership { tenantId: string; roleIds: string[]; + /** + * Vidi vsechny tickety firmy, ne jen svoje a svych sekci. + * + * Je to **postaveni uctu ve firme**, ne vlastnost resitele, proto to visi + * tady a ne na `Person`: clovek muze byt ve dvou firmach jednou reditel + * a jednou brigadnik. + * + * `undefined` u zaznamu ulozenych driv. Co pak plati, resi `visibilityFor` + * v data/access.ts - nemuze to byt tise `false`, jinak by pri nasazeni + * kazdemu spravci zmizela vetsina ticketu. + */ + seesAllTenant?: boolean; } export interface User { diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index de48bd0..f3a4c7b 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -15,7 +15,6 @@ import { ScrollText, Settings, ShieldAlert, - Users, Workflow, X, } from 'lucide-react'; @@ -48,7 +47,7 @@ const moduleRoutes: Record ({ personId, seesAll: false })); +} + +const inputClass = + 'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none'; + +export function GroupPanel() { + const groups = useApiQuery<{ items: Group[] }>('/api/dashboard/settings/groups'); + const people = useApiQuery('/api/dashboard/people'); + + const [openId, setOpenId] = useState(null); + const [newName, setNewName] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const items = useMemo(() => groups.data?.items ?? [], [groups.data]); + const persons = people.data?.items ?? []; + + // Prvni sekce se otevre sama. Prazdny panel vedle seznamu nic nerika. + useEffect(() => { + if (openId === null && items.length > 0) setOpenId(items[0].id); + }, [items, openId]); + + const open = items.find((group) => group.id === openId) ?? null; + + async function save(id: string, patch: Partial) { + setBusy(true); + setError(null); + try { + await apiFetch(`/api/dashboard/settings/groups/${id}`, { method: 'PATCH', body: patch }); + groups.reload(); + } catch (err) { + console.error('[skupiny] ulozeni selhalo:', err); + setError(err instanceof Error ? err.message : 'Změnu se nepodařilo uložit.'); + } finally { + setBusy(false); + } + } + + async function create() { + const name = newName.trim(); + if (name.length < 2) return; + setBusy(true); + setError(null); + try { + const created = await apiFetch('/api/dashboard/settings/groups', { + method: 'POST', + body: { name, members: [] }, + }); + setNewName(''); + groups.reload(); + setOpenId(created.id); + } catch (err) { + console.error('[skupiny] zalozeni selhalo:', err); + setError(err instanceof Error ? err.message : 'Sekci se nepodařilo založit.'); + } finally { + setBusy(false); + } + } + + async function remove(id: string) { + setBusy(true); + setError(null); + try { + await apiFetch(`/api/dashboard/settings/groups/${id}`, { method: 'DELETE' }); + setOpenId(null); + groups.reload(); + } catch (err) { + console.error('[skupiny] smazani selhalo:', err); + setError(err instanceof Error ? err.message : 'Sekci se nepodařilo smazat.'); + } finally { + setBusy(false); + } + } + + /** Prida nebo odebere cloveka. Priznak si nove clenstvi nese vypnuty. */ + function toggleMember(group: Group, personId: string) { + const current = membersOf(group); + const next = current.some((member) => member.personId === personId) + ? current.filter((member) => member.personId !== personId) + : [...current, { personId, seesAll: false }]; + void save(group.id, { members: next }); + } + + /** Prepne "vidi vse ve skupine" u jednoho clenstvi. */ + function toggleSeesAll(group: Group, personId: string) { + const next = membersOf(group).map((member) => + member.personId === personId ? { ...member, seesAll: !member.seesAll } : member, + ); + void save(group.id, { members: next }); + } + + return ( + { + groups.reload(); + people.reload(); + }} + > + {error && ( +

+ {error} +

+ )} + +
+ {/* seznam sekci */} +
+
    + {items.map((group) => ( +
  • + +
  • + ))} +
+ +
+ + setNewName(event.target.value)} + placeholder="Nová sekce" + className={inputClass} + /> + +
+
+ + {/* clenove otevrene sekce */} + {open ? ( +
+
+
+

{open.name}

+

+ Zaškrtnutý člověk vidí všechny tickety sekce, i ty, které si vzali kolegové. + Nezaškrtnutý vidí jen svoje. +

+
+ +
+ +
    + {persons.map((person) => { + const member = membersOf(open).find((item) => item.personId === person.id); + return ( +
  • + + + + {person.name} + {person.role} + + + {/* + Prepinac viditelnosti dava smysl jen u cloveka, ktery + v sekci opravdu je. U neclena by to byl priznak bez + clenstvi, na kterem by visel. + */} + {member && ( + + )} +
  • + ); + })} +
+ + {persons.length === 0 && ( +

+ Ve firmě zatím nikdo není. Nejdřív přidejte řešitele. +

+ )} +
+ ) : ( +

+ Zatím tu není žádná sekce. Založte první vlevo. +

+ )} +
+
+ ); +} diff --git a/web/src/components/dashboard/RolesAdmin.tsx b/web/src/components/dashboard/RolesAdmin.tsx new file mode 100644 index 0000000..b8ea12f --- /dev/null +++ b/web/src/components/dashboard/RolesAdmin.tsx @@ -0,0 +1,78 @@ +/** + * Role a prava firmy. + * + * Vytazeno ze stranky nastaveni do vlastni komponenty, protoze to patri + * pod zalozku Firma - je to nastaveni firmy, ne uctu. + */ + +import { useMemo } from 'react'; +import { Badge } from '@/components/ui/Badge'; +import { EntityAdmin } from '@/components/dashboard/EntityAdmin'; + +import { useApiQuery } from '@/lib/useApiQuery'; + +interface CatalogItem { + key: string; + label: string; + hint?: string; + group: string; +} + +/** Zaznam ciselniku tak, jak ho vraci EntityAdmin. Stejny tvar jako v nastaveni. */ +interface NamedRecord { + id: string; + name?: string; + label?: string; + [key: string]: unknown; +} + +export function RolesAdmin() { + const catalog = useApiQuery<{ permissions: CatalogItem[] }>('/api/dashboard/settings/catalog'); + + const permissionOptions = useMemo( + () => + (catalog.data?.permissions ?? []).map((item) => ({ + value: item.key, + label: item.hint ? `${item.label} (${item.hint})` : item.label, + group: item.group, + })), + [catalog.data], + ); + + return ( + ( + + {String(role.name ?? '')} + {role.system === true && systémová} + + ), + }, + { label: 'Popis', render: (role: NamedRecord) => String(role.description ?? '') }, + { + label: 'Práv', + narrow: true, + render: (role: NamedRecord) => String((role.permissions as string[] | undefined)?.length ?? 0), + }, + ]} + canDelete={(role: NamedRecord) => role.system !== true} + fields={[ + { name: 'name', label: 'Název role', kind: 'text', required: true }, + { name: 'description', label: 'Popis', kind: 'text' }, + { + name: 'permissions', + label: 'Práva', + kind: 'multiselect', + options: permissionOptions, + hint: 'Práva k akcím na ticketu se objeví, jakmile akci založíte.', + }, + ]} + /> + ); +} diff --git a/web/src/components/dashboard/TicketBody.tsx b/web/src/components/dashboard/TicketBody.tsx index 90d8fdf..1206953 100644 --- a/web/src/components/dashboard/TicketBody.tsx +++ b/web/src/components/dashboard/TicketBody.tsx @@ -60,12 +60,35 @@ function tryParse(text: string): unknown { } } +/** + * Jednoradkovy nahled do seznamu. + * + * V seznamu se tabulka nevykresli, ale ani tam nema svitit `{"result":...}`. + * Kdyz je telo struktura, slozi se z nej par dvojic klic a hodnota - je to + * to jedine, co na jednom radku dava smysl precist. + */ +export function bodyPreview(body: string): string { + const { text, data } = parseBody(body); + if (data === undefined) return text; + + const pairs = Array.isArray(data) + ? [`${data.length} položek`] + : Object.entries(data as Record) + .filter(([, value]) => value !== null && typeof value !== 'object') + .slice(0, 4) + .map(([key, value]) => `${key}: ${String(value)}`); + + const summary = pairs.join(', '); + if (summary === '') return text; + return text === '' ? summary : `${text} - ${summary}`; +} + export function TicketBody({ body, sourceRef }: { body: string; sourceRef?: string | null }) { const { text, data } = parseBody(body); if (text === '' && data === undefined) { return ( -

+

Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text původního požadavku nikam neuložil.

@@ -73,7 +96,7 @@ export function TicketBody({ body, sourceRef }: { body: string; sourceRef?: stri } return ( -
+
{text !== '' && (

{text}

)} diff --git a/web/src/components/dashboard/TicketTypesAdmin.tsx b/web/src/components/dashboard/TicketTypesAdmin.tsx new file mode 100644 index 0000000..6f9108f --- /dev/null +++ b/web/src/components/dashboard/TicketTypesAdmin.tsx @@ -0,0 +1,72 @@ +/** + * Typy ticketu a jejich vlastni pole. + * + * Vytazeno ze stranky nastaveni do vlastni komponenty ze stejneho duvodu + * jako role: patri to pod zalozku Firma. + */ + +import { EntityAdmin, parseJsonField } from '@/components/dashboard/EntityAdmin'; + + +/** Zaznam ciselniku tak, jak ho vraci EntityAdmin. Stejny tvar jako v nastaveni. */ +interface NamedRecord { + id: string; + name?: string; + label?: string; + [key: string]: unknown; +} + +export function TicketTypesAdmin() { + return ( + String(type.name ?? '') }, + { label: 'Klíč', render: (type: NamedRecord) => {String(type.key ?? '')} }, + { + label: 'Polí', + narrow: true, + render: (type: NamedRecord) => String((type.fields as unknown[] | undefined)?.length ?? 0), + }, + ]} + fields={[ + { name: 'name', label: 'Název', kind: 'text', required: true }, + { + name: 'key', + label: 'Klíč', + kind: 'text', + required: true, + createOnly: true, + hint: 'Malá písmena a pomlčky, například order. Používá se ve filtrech.', + }, + { name: 'icon', label: 'Ikona', kind: 'text', initial: 'LifeBuoy' }, + { + name: 'statuses', + label: 'Vlastní stavy', + kind: 'tags', + hint: 'Prázdné = výchozí čtveřice ticketu.', + }, + { + name: 'fields', + label: 'Vlastní pole', + kind: 'json', + initial: '[]', + hint: 'Seznam polí: key, label, type (string/number/boolean/date), required.', + }, + ]} + toBody={(values, entity) => ({ + ...(entity ? {} : { key: values.key }), + name: values.name, + icon: values.icon, + statuses: values.statuses, + fields: parseJsonField(values.fields, []), + })} + toForm={(type: NamedRecord) => ({ + ...type, + fields: JSON.stringify(type.fields ?? [], null, 2), + })} + /> + ); +} diff --git a/web/src/pages/dashboard/Helpdesk.tsx b/web/src/pages/dashboard/Helpdesk.tsx index 7fe49ae..7243dc8 100644 --- a/web/src/pages/dashboard/Helpdesk.tsx +++ b/web/src/pages/dashboard/Helpdesk.tsx @@ -2,6 +2,7 @@ import { MessageSquarePlus, Plus, Send } from 'lucide-react'; import { useEffect, useState } from 'react'; import { DataState } from '@/components/dashboard/DataState'; import { TicketStatusBadge } from '@/components/dashboard/StatusBadge'; +import { TicketBody, bodyPreview } from '@/components/dashboard/TicketBody'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; @@ -114,7 +115,7 @@ export default function Helpdesk() {

{ticket.subject}

{ticket.body && ( -

{ticket.body}

+

{bodyPreview(ticket.body)}

)} @@ -323,9 +324,9 @@ function RequestDetail({
{ticket.body && ( -

- {ticket.body} -

+
+ +
)}
diff --git a/web/src/pages/dashboard/People.tsx b/web/src/pages/dashboard/People.tsx index 9402211..0490aaf 100644 --- a/web/src/pages/dashboard/People.tsx +++ b/web/src/pages/dashboard/People.tsx @@ -3,6 +3,9 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { DataState } from '@/components/dashboard/DataState'; import { EntityAdmin } from '@/components/dashboard/EntityAdmin'; +import { GroupPanel } from '@/components/dashboard/GroupPanel'; +import { RolesAdmin } from '@/components/dashboard/RolesAdmin'; +import { TicketTypesAdmin } from '@/components/dashboard/TicketTypesAdmin'; import { InvitePanel } from '@/components/dashboard/InvitePanel'; import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch'; import { Badge } from '@/components/ui/Badge'; @@ -24,7 +27,7 @@ interface NamedRecord { * dlazdice na rychly prehled - proto obojí, ne jen jedno. */ export default function People() { - usePageMeta({ title: 'Lidé', area: 'portal' }); + usePageMeta({ title: 'Firma', area: 'portal' }); const [view, setView] = useState('table'); const [tab, setTab] = useState('prehled'); @@ -44,12 +47,6 @@ export default function People() { return (permission: string) => owned.has(permission); }, [access.data]); - /** Cleny skupiny se vybiraji klikanim. Opisovat ID je zdroj preklepu. */ - const personOptions = useMemo( - () => items.map((person) => ({ value: person.id, label: person.name })), - [items], - ); - /* * Vsechno o lidech je na jedne strance, ne v nastaveni. Pozvat kolegu * a zalozit skupinu neni nastaveni portalu, je to bezna denni prace - a dokud @@ -58,7 +55,9 @@ export default function People() { const tabs = [ { key: 'prehled', label: 'Přehled', show: true }, { key: 'resitele', label: 'Řešitelé', show: can('people.manage') }, - { key: 'skupiny', label: 'Skupiny', show: can('group.manage') }, + { key: 'skupiny', label: 'Sekce', show: can('group.manage') }, + { key: 'role', label: 'Role a práva', show: can('role.manage') }, + { key: 'typy', label: 'Typy ticketů', show: can('ticketType.manage') }, { key: 'pozvanky', label: 'Pozvánky', show: can('user.manage') }, ].filter((item) => item.show); @@ -66,9 +65,9 @@ export default function People() {
-

Lidé

+

Firma

- Kdo řeší tickety, co má u sebe a jak dlouho mu to leží. + Lidé, sekce a to, kdo co vidí. Všechno za firmu, kterou máte přepnutou nahoře.

{tab === 'prehled' && } @@ -116,7 +115,7 @@ export default function People() { error={people.error} onRetry={people.reload} empty={items.length === 0} - emptyLabel="Firma zatím nemá žádné řešitele. Přidejte je v Nastavení." + emptyLabel="Firma zatím nemá žádné řešitele. Přidejte je v záložce Řešitelé." > {view === 'table' ? (
@@ -286,33 +285,11 @@ export default function People() { /> )} - {tab === 'skupiny' && ( - String(group.name ?? '') }, - { - label: 'Členů', - narrow: true, - render: (group: NamedRecord) => - String((group.personIds as string[] | undefined)?.length ?? 0), - }, - ]} - fields={[ - { name: 'name', label: 'Název', kind: 'text', required: true }, - { - name: 'personIds', - label: 'Členové', - kind: 'multiselect', - options: personOptions, - hint: 'Automatické přidělování se nastavuje v kroku automatizace, ne tady.', - }, - ]} - /> - )} + {tab === 'skupiny' && } + + {tab === 'role' && } + + {tab === 'typy' && } {tab === 'pozvanky' && }
diff --git a/web/src/pages/dashboard/Settings.tsx b/web/src/pages/dashboard/Settings.tsx index 4c7dced..ae74d79 100644 --- a/web/src/pages/dashboard/Settings.tsx +++ b/web/src/pages/dashboard/Settings.tsx @@ -66,16 +66,6 @@ export default function Settings() { return (permission: string) => owned.has(permission); }, [access.data]); - const permissionOptions = useMemo( - () => - (catalog.data?.permissions ?? []).map((item) => ({ - value: item.key, - label: item.hint ? `${item.label} (${item.hint})` : item.label, - group: item.group, - })), - [catalog.data], - ); - const platformAdmin = user?.platformAdmin === true; /** @@ -85,8 +75,6 @@ export default function Settings() { const tabs = useMemo(() => { const all: Array<{ key: string; label: string; show: boolean }> = [ { key: 'ucet', label: 'Můj účet', show: true }, - { key: 'roles', label: 'Role a práva', show: can('role.manage') }, - { key: 'types', label: 'Typy ticketů', show: can('ticketType.manage') }, { key: 'users', label: 'Uživatelé', show: platformAdmin }, { key: 'tenants', label: 'Firmy', show: platformAdmin }, { key: 'features', label: 'Záložky firmy', show: platformAdmin }, @@ -125,97 +113,7 @@ export default function Settings() { {tab === 'ucet' && } - {tab === 'roles' && ( - ( - - {String(role.name ?? '')} - {role.system === true && systémová} - - ), - }, - { label: 'Popis', render: (role: NamedRecord) => String(role.description ?? '') }, - { - label: 'Práv', - narrow: true, - render: (role: NamedRecord) => String((role.permissions as string[] | undefined)?.length ?? 0), - }, - ]} - canDelete={(role: NamedRecord) => role.system !== true} - fields={[ - { name: 'name', label: 'Název role', kind: 'text', required: true }, - { name: 'description', label: 'Popis', kind: 'text' }, - { - name: 'permissions', - label: 'Práva', - kind: 'multiselect', - options: permissionOptions, - hint: 'Práva k akcím na ticketu se objeví, jakmile akci založíte.', - }, - ]} - /> - )} - - {tab === 'types' && ( - String(type.name ?? '') }, - { label: 'Klíč', render: (type: NamedRecord) => {String(type.key ?? '')} }, - { - label: 'Polí', - narrow: true, - render: (type: NamedRecord) => String((type.fields as unknown[] | undefined)?.length ?? 0), - }, - ]} - fields={[ - { name: 'name', label: 'Název', kind: 'text', required: true }, - { - name: 'key', - label: 'Klíč', - kind: 'text', - required: true, - createOnly: true, - hint: 'Malá písmena a pomlčky, například order. Používá se ve filtrech.', - }, - { name: 'icon', label: 'Ikona', kind: 'text', initial: 'LifeBuoy' }, - { - name: 'statuses', - label: 'Vlastní stavy', - kind: 'tags', - hint: 'Prázdné = výchozí čtveřice ticketu.', - }, - { - name: 'fields', - label: 'Vlastní pole', - kind: 'json', - initial: '[]', - hint: 'Seznam polí: key, label, type (string/number/boolean/date), required.', - }, - ]} - toBody={(values, entity) => ({ - ...(entity ? {} : { key: values.key }), - name: values.name, - icon: values.icon, - statuses: values.statuses, - fields: parseJsonField(values.fields, []), - })} - toForm={(type: NamedRecord) => ({ - ...type, - fields: JSON.stringify(type.fields ?? [], null, 2), - })} - /> - )} - - {tab === 'users' && } + {tab === 'users' && } {tab === 'tenants' && ( Obsah požadavku - +
+ +