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 - +
+ +