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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a6722f9379
commit
6bcd7f845c
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+73
-1
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+48
-3
@@ -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>('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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+88
-11
@@ -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<string | null>(
|
||||
(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<string, string | number | boolean | null>;
|
||||
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,
|
||||
};
|
||||
|
||||
+46
-9
@@ -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<string, unknown>, 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);
|
||||
});
|
||||
|
||||
|
||||
+14
-2
@@ -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',
|
||||
|
||||
+15
-3
@@ -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<typeof groupCreate>) => ({
|
||||
name: input.name,
|
||||
personIds: input.personIds,
|
||||
members: input.members,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<T>(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)) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Handler> = {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -48,7 +47,7 @@ const moduleRoutes: Record<string, { to: string; icon: typeof LayoutDashboard; e
|
||||
scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false },
|
||||
tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false },
|
||||
helpdesk: { to: '/dashboard/helpdesk', icon: MessageSquarePlus, end: false },
|
||||
people: { to: '/dashboard/lide', icon: Users, end: false },
|
||||
people: { to: '/dashboard/lide', icon: Building2, end: false },
|
||||
actions: { to: '/dashboard/akce', icon: MousePointerClick, end: false },
|
||||
widgets: { to: '/dashboard/widgety', icon: LayoutGrid, end: false },
|
||||
incidents: { to: '/dashboard/incidenty', icon: AlarmClock, end: false },
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Sekce firmy a kdo v nich co vidi.
|
||||
*
|
||||
* Proc to neni obecny `EntityAdmin` jako ostatni ciselniky: u skupiny nejde
|
||||
* jen o seznam jmen. U **kazdeho clenstvi** je prepinac "vidi vse ve skupine",
|
||||
* a to obecny editor s poli neumi - potrebuje to radek na cloveka.
|
||||
*
|
||||
* Priznak visi na clenstvi zamerne, ne na cloveku a ne na skupine. Diky tomu
|
||||
* muze byt clovek v peti sekcich a jen ve dvou z nich videt vsechno. Role to
|
||||
* rict neumi, ta je jedna na celou firmu.
|
||||
*/
|
||||
|
||||
import { Check, Plus, Trash2, Users } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import type { PeopleResponse } from '@/types/dashboard';
|
||||
|
||||
interface GroupMember {
|
||||
personId: string;
|
||||
seesAll: boolean;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
members?: GroupMember[];
|
||||
/** Stara podoba clenstvi u zaznamu ulozenych driv. */
|
||||
personIds?: string[];
|
||||
}
|
||||
|
||||
/** Clenove skupiny, at uz je zaznam v jakekoliv podobe. Zrcadli data/people.ts. */
|
||||
function membersOf(group: Group): GroupMember[] {
|
||||
if (group.members) return group.members;
|
||||
return (group.personIds ?? []).map((personId) => ({ 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<PeopleResponse>('/api/dashboard/people');
|
||||
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<Group>) {
|
||||
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<Group>('/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 (
|
||||
<DataState
|
||||
loading={groups.loading || people.loading}
|
||||
error={groups.error ?? people.error}
|
||||
onRetry={() => {
|
||||
groups.reload();
|
||||
people.reload();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<p className="mb-4 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
|
||||
{/* seznam sekci */}
|
||||
<div className="space-y-3">
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((group) => (
|
||||
<li key={group.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(group.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl border px-3.5 py-2.5 text-left transition-colors',
|
||||
group.id === openId
|
||||
? 'border-brand-400/50 bg-brand-500/10'
|
||||
: 'border-ink-600/60 hover:border-ink-600',
|
||||
)}
|
||||
>
|
||||
<Users className="size-4 shrink-0 text-brand-300" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="block text-xs text-white/40">
|
||||
{membersOf(group).length} lidí
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<label htmlFor="nova-sekce" className="sr-only">
|
||||
Název nové sekce
|
||||
</label>
|
||||
<input
|
||||
id="nova-sekce"
|
||||
value={newName}
|
||||
disabled={busy}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder="Nová sekce"
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button size="sm" disabled={busy || newName.trim().length < 2} onClick={() => void create()}>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* clenove otevrene sekce */}
|
||||
{open ? (
|
||||
<div className="glass rounded-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{open.name}</h3>
|
||||
<p className="mt-1 text-sm text-white/45">
|
||||
Zaškrtnutý člověk vidí všechny tickety sekce, i ty, které si vzali kolegové.
|
||||
Nezaškrtnutý vidí jen svoje.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(open.id)}
|
||||
className="text-danger-400"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Smazat sekci
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 divide-y divide-ink-600/40">
|
||||
{persons.map((person) => {
|
||||
const member = membersOf(open).find((item) => item.personId === person.id);
|
||||
return (
|
||||
<li key={person.id} className="flex items-center gap-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => toggleMember(open, person.id)}
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors',
|
||||
member
|
||||
? 'border-brand-400 bg-brand-400 text-ink-950'
|
||||
: 'border-ink-600 hover:border-white/40',
|
||||
)}
|
||||
aria-label={member ? `Odebrat ${person.name}` : `Přidat ${person.name}`}
|
||||
>
|
||||
{member && <Check className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-white">{person.name}</span>
|
||||
<span className="block truncate text-xs text-white/35">{person.role}</span>
|
||||
</span>
|
||||
|
||||
{/*
|
||||
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 && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-white/55">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={member.seesAll}
|
||||
disabled={busy}
|
||||
onChange={() => toggleSeesAll(open, person.id)}
|
||||
className="size-4 accent-brand-400"
|
||||
/>
|
||||
vidí vše ze sekce
|
||||
</label>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{persons.length === 0 && (
|
||||
<p className="mt-4 text-sm text-white/40">
|
||||
Ve firmě zatím nikdo není. Nejdřív přidejte řešitele.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="glass rounded-card p-5 text-sm text-white/40">
|
||||
Zatím tu není žádná sekce. Založte první vlevo.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<EntityAdmin
|
||||
title="Role a práva"
|
||||
description="Práva jsou data, ne pevný seznam v kódu. Systémové role se nedají měnit."
|
||||
endpoint="/api/dashboard/settings/roles"
|
||||
columns={[
|
||||
{
|
||||
label: 'Role',
|
||||
render: (role: NamedRecord) => (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{String(role.name ?? '')}
|
||||
{role.system === true && <Badge tone="neutral">systémová</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ 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.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>)
|
||||
.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 (
|
||||
<p className="mt-3 rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text původního
|
||||
požadavku nikam neuložil.
|
||||
</p>
|
||||
@@ -73,7 +96,7 @@ export function TicketBody({ body, sourceRef }: { body: string; sourceRef?: stri
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-3">
|
||||
{text !== '' && (
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-white/75">{text}</p>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<EntityAdmin
|
||||
title="Typy ticketů"
|
||||
description="Za typem stojí vlastní pole, proto se na typ vážou akce. Tag je jen nálepka."
|
||||
endpoint="/api/dashboard/settings/ticket-types"
|
||||
columns={[
|
||||
{ label: 'Název', render: (type: NamedRecord) => String(type.name ?? '') },
|
||||
{ label: 'Klíč', render: (type: NamedRecord) => <code>{String(type.key ?? '')}</code> },
|
||||
{
|
||||
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),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
<p className="mt-1.5 font-medium text-white">{ticket.subject}</p>
|
||||
{ticket.body && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-white/45">{ticket.body}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-white/45">{bodyPreview(ticket.body)}</p>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
@@ -323,9 +324,9 @@ function RequestDetail({
|
||||
</div>
|
||||
|
||||
{ticket.body && (
|
||||
<p className="rounded-card border border-ink-600/50 bg-ink-850/50 p-4 text-sm whitespace-pre-wrap text-white/70">
|
||||
{ticket.body}
|
||||
</p>
|
||||
<div className="rounded-card border border-ink-600/50 bg-ink-850/50 p-4">
|
||||
<TicketBody body={ticket.body} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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<ListView>('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() {
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Lidé</h1>
|
||||
<h1 className="text-2xl font-bold text-white">Firma</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{tab === 'prehled' && <ViewSwitch view={view} onChange={setView} />}
|
||||
@@ -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' ? (
|
||||
<div className="glass overflow-x-auto rounded-card">
|
||||
@@ -286,33 +285,11 @@ export default function People() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'skupiny' && (
|
||||
<EntityAdmin
|
||||
title="Skupiny řešitelů"
|
||||
description="Ticket může ležet ve frontě skupiny. Kdo je ve skupině, si ho převezme sám."
|
||||
endpoint="/api/dashboard/settings/groups"
|
||||
onChanged={people.reload}
|
||||
columns={[
|
||||
{ label: 'Název', render: (group: NamedRecord) => 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' && <GroupPanel />}
|
||||
|
||||
{tab === 'role' && <RolesAdmin />}
|
||||
|
||||
{tab === 'typy' && <TicketTypesAdmin />}
|
||||
|
||||
{tab === 'pozvanky' && <InvitePanel />}
|
||||
</div>
|
||||
|
||||
@@ -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' && <MyAccount />}
|
||||
|
||||
{tab === 'roles' && (
|
||||
<EntityAdmin
|
||||
title="Role a práva"
|
||||
description="Práva jsou data, ne pevný seznam v kódu. Systémové role se nedají měnit."
|
||||
endpoint="/api/dashboard/settings/roles"
|
||||
columns={[
|
||||
{
|
||||
label: 'Role',
|
||||
render: (role: NamedRecord) => (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{String(role.name ?? '')}
|
||||
{role.system === true && <Badge tone="neutral">systémová</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ 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' && (
|
||||
<EntityAdmin
|
||||
title="Typy ticketů"
|
||||
description="Za typem stojí vlastní pole, proto se na typ vážou akce. Tag je jen nálepka."
|
||||
endpoint="/api/dashboard/settings/ticket-types"
|
||||
columns={[
|
||||
{ label: 'Název', render: (type: NamedRecord) => String(type.name ?? '') },
|
||||
{ label: 'Klíč', render: (type: NamedRecord) => <code>{String(type.key ?? '')}</code> },
|
||||
{
|
||||
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' && <UsersAdmin />}
|
||||
{tab === 'users' && <UsersAdmin />}
|
||||
|
||||
{tab === 'tenants' && (
|
||||
<EntityAdmin
|
||||
|
||||
@@ -200,7 +200,9 @@ export default function TicketDetail() {
|
||||
<FileText className="size-4 text-brand-300" />
|
||||
Obsah požadavku
|
||||
</h2>
|
||||
<TicketBody body={ticket.data.body} sourceRef={ticket.data.sourceRef} />
|
||||
<div className="mt-3">
|
||||
<TicketBody body={ticket.data.body} sourceRef={ticket.data.sourceRef} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
|
||||
Reference in New Issue
Block a user