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