Uloziste pro vsechna data, oprava .gitignore, dodelany navrh rozsireni

.gitignore mel vzorec `data/`, ktery se shodl i se `src/data/`. Sestnact
zdrojovych souboru tim tise chybelo v gitu vcetne cele slozky
`src/data/store/`. Opraveno na `/data/`, stejne v .dockerignore.

Tickety vcetne logu, automatizace, incidenty a rozlozeni dashboardu se po
kazde zmene ukladaji. Pomocnik `withMirror` je opak `withCache`: data se meni
v pameti a zapisuji cela, misto aby se po zapisu znovu nacitala. Citace ID se
pri startu dopocitaji z ulozenych zaznamu, takze novy ticket neprepise stary.

Detail ticketu umi typ, tagy, vlastni pole typu a prehozeni na skupinu.
Nastaveni ma prepnuti spravce na jiny ucet, vychozi jen pro cteni.
Skupiny resitelu chodi spolu s lidmi jednim requestem.

Dokumentace: rejstrik znovupouzitelnych funkci (15), navrh monetizace
a ceny za krok (16), popis nastaveni a prav (17). Doplneny endpointy
do openapi.ts, petice CRUD rout se generuje jednou funkci.

Overeno v rezimu souboru: zmeny prezily tvrde ukonceni procesu a po restartu
byly zpatky vcetne logu ticketu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 07:46:22 +02:00
co-authored by Claude Opus 5
parent e7cf499a0b
commit afbe948da3
37 changed files with 5594 additions and 106 deletions
+71 -6
View File
@@ -2,11 +2,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import type { ReactNode } from 'react';
import { apiFetch, getToken, setToken } from '@/lib/api';
export type TenantRole = 'admin' | 'agent';
/**
* Clenstvi ve firme. `roleIds` je seznam, protoze role uz nejsou dva pevne
* stavy - viz src/data/permissions.ts na serveru.
*/
export interface Membership {
tenantId: string;
role: TenantRole;
roleIds: string[];
}
export interface AuthUser {
@@ -19,19 +21,39 @@ export interface AuthUser {
memberships: Membership[];
}
/** Kdyz je prihlaseni prepnute na jiny ucet, tady je na koho. */
export interface ImpersonationState {
userId: string;
userName: string;
allowWrites: boolean;
}
interface AuthState {
user: AuthUser | null;
/** true dokud probiha prvotni overeni ulozeneho tokenu */
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Vyplnene jen pri prepnuti na jiny ucet. */
impersonation: ImpersonationState | null;
startImpersonation: (userId: string, allowWrites: boolean) => Promise<void>;
stopImpersonation: () => void;
}
/**
* Kde se drzi vlastni token pri prepnuti na jiny ucet.
*
* Server token impersonace nezneplatnuje - JWT je bezstavovy. Klient si proto
* svuj puvodni token odklada a pri ukonceni se k nemu vrati.
*/
const OWN_TOKEN_KEY = 'automia.ownToken';
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(null);
// Pri startu zkusime obnovit session z ulozeneho tokenu.
useEffect(() => {
@@ -44,7 +66,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
try {
const data = await apiFetch<{ user: AuthUser }>('/api/auth/me');
if (!cancelled) setUser(data.user);
if (!cancelled) {
setUser(data.user);
// Odlozeny vlastni token znamena, ze jsme prepnuti na jiny ucet.
if (sessionStorage.getItem(OWN_TOKEN_KEY)) {
setImpersonation({
userId: data.user.id,
userName: data.user.name,
// Presny rezim vi jen server, klient jen ze je prepnuty.
allowWrites: false,
});
}
}
} catch (err) {
// Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny.
console.warn('[auth] obnoveni session selhalo, mazu token:', err);
@@ -71,6 +104,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user);
}, []);
const startImpersonation = useCallback(async (userId: string, allowWrites: boolean) => {
const data = await apiFetch<{
token: string;
user: { id: string; name: string; email: string };
allowWrites: boolean;
}>('/api/admin/impersonate', { method: 'POST', body: { userId, allowWrites } });
const own = getToken();
if (own) sessionStorage.setItem(OWN_TOKEN_KEY, own);
setToken(data.token);
setImpersonation({
userId: data.user.id,
userName: data.user.name,
allowWrites: data.allowWrites,
});
// Cela stranka se nacte znovu, aby se prekreslily zalozky i prava.
window.location.reload();
}, []);
const stopImpersonation = useCallback(() => {
const own = sessionStorage.getItem(OWN_TOKEN_KEY);
sessionStorage.removeItem(OWN_TOKEN_KEY);
// Server o konci jen informujeme kvuli auditu, token expiruje sam.
void apiFetch('/api/admin/impersonate/stop', { method: 'POST' }).catch(() => undefined);
setToken(own);
setImpersonation(null);
window.location.reload();
}, []);
const logout = useCallback(async () => {
try {
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
@@ -79,13 +142,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.warn('[auth] logout na serveru selhal, odhlasuji lokalne:', err);
} finally {
setToken(null);
sessionStorage.removeItem(OWN_TOKEN_KEY);
setUser(null);
setImpersonation(null);
}
}, []);
const value = useMemo<AuthState>(
() => ({ user, loading, login, logout }),
[user, loading, login, logout],
() => ({ user, loading, login, logout, impersonation, startImpersonation, stopImpersonation }),
[user, loading, login, logout, impersonation, startImpersonation, stopImpersonation],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -10,6 +10,7 @@ import {
Blocks,
ScrollText,
Settings,
ShieldAlert,
Workflow,
X,
} from 'lucide-react';
@@ -25,16 +26,23 @@ import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import type { Access } from '@/types/dashboard';
const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
{ to: '/dashboard/sluzby', label: 'Služby', icon: Blocks, end: false },
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
{ to: '/dashboard/skripty', label: 'Skripty', icon: ScrollText, end: false },
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
];
/**
* Mapa modulu na cesty a ikony.
*
* **Ktere zalozky se zobrazi, rozhoduje server** (`access.nav`) - je to prunik
* toho, co ma firma zapnute, a toho, na co ma uzivatel pravo. Klient zna jen
* to, kam ktery modul vede a jakou ma ikonu.
*/
const moduleRoutes: Record<string, { to: string; icon: typeof LayoutDashboard; end: boolean }> = {
overview: { to: '/dashboard', icon: LayoutDashboard, end: true },
automations: { to: '/dashboard/automatizace', icon: Workflow, end: false },
services: { to: '/dashboard/sluzby', icon: Blocks, end: false },
connectors: { to: '/dashboard/konektory', icon: Plug, end: false },
scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false },
tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false },
incidents: { to: '/dashboard/incidenty', icon: AlarmClock, end: false },
settings: { to: '/dashboard/nastaveni', icon: Settings, end: false },
};
/**
* Shell klientskeho portalu: fixni sidebar (desktop) / vysouvaci (mobil) + obsah.
@@ -54,8 +62,9 @@ function accessLabel(user: AuthUser | null, access: Access | null): string {
if (user?.platformAdmin) return 'Správce platformy, vidí všechny firmy';
const count = access?.tenants.length ?? 0;
if (count > 1) return `Přístup do ${count} firem`;
const role = user?.memberships[0]?.role;
return role === 'admin' ? 'Správce firmy' : 'Řešitel';
// Role uz nejsou dva pevne stavy, takze se ukazuje pocet prav.
const permissions = access?.permissions.length ?? 0;
return permissions > 12 ? 'Správce firmy' : 'Řešitel';
}
function DashboardShell() {
@@ -97,13 +106,15 @@ function DashboardShell() {
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{nav.map((item) => {
const Icon = item.icon;
{(access.data?.nav ?? []).map((entry) => {
const route = moduleRoutes[entry.key];
if (!route) return null;
const Icon = route.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.end}
key={entry.key}
to={route.to}
end={route.end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-colors',
@@ -114,7 +125,7 @@ function DashboardShell() {
}
>
<Icon className="size-4 shrink-0" />
{item.label}
{entry.label}
</NavLink>
);
})}
@@ -160,6 +171,8 @@ function DashboardShell() {
)}
<div className="lg:pl-64">
<ImpersonationBanner />
{/* Topbar */}
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-ink-600/60 bg-ink-900/85 px-5 backdrop-blur-xl sm:px-7">
<button
@@ -202,6 +215,34 @@ function DashboardShell() {
);
}
/**
* Pruh pri prepnuti na jiny ucet.
*
* Bez nej clovek zapomene a udela neco cizim jmenem. Proto je vyrazny a je
* na kazde strance.
*/
function ImpersonationBanner() {
const { impersonation, stopImpersonation } = useAuth();
if (!impersonation) return null;
return (
<div className="flex flex-wrap items-center gap-3 bg-warn-500/15 px-5 py-2 text-sm text-warn-400 sm:px-7">
<ShieldAlert className="size-4 shrink-0" />
<span>
Jste přihlášený za <strong>{impersonation.userName}</strong>
{impersonation.allowWrites ? ' včetně zápisu' : ' jen pro čtení'}.
</span>
<button
type="button"
onClick={stopImpersonation}
className="ml-auto rounded-lg border border-warn-400/50 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-warn-500/20"
>
Zpět na svůj účet
</button>
</div>
);
}
function initials(name: string | undefined): string {
if (!name) return '?';
return name
@@ -0,0 +1,540 @@
import { AlertTriangle, Pencil, Plus, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
/**
* Obecna sprava zaznamu: tabulka, dialog na zalozeni a upravu, mazani.
*
* Deset stranek nastaveni by jinak znamenalo desetkrat tentyz kod na nacteni,
* formular, chybu a mazani. Rozdily jsou jen ve dvou vecech: jake sloupce
* ukazat a jaka pole vyplnit. Obojí je proto parametr.
*
* Serverova strana ma stejny princip, viz `src/routes/crud.ts`.
*/
export type FieldKind =
| 'text'
| 'textarea'
| 'number'
| 'checkbox'
| 'select'
| 'multiselect'
| 'tags'
| 'json';
export interface AdminField<T> {
/** Klic v tele requestu. */
name: string;
label: string;
kind: FieldKind;
required?: boolean;
hint?: string;
/** Volby u `select` a `multiselect`. */
options?: Array<{ value: string; label: string; group?: string }>;
/** Jen pri zakladani, napr. heslo. */
createOnly?: boolean;
/** false = pole se nezobrazi (napr. podle prav). */
visible?: (entity: T | null) => boolean;
/** Vychozi hodnota pri zakladani. */
initial?: unknown;
}
export interface AdminColumn<T> {
label: string;
render: (entity: T) => ReactNode;
/** Uzsi sloupec pro cisla a znacky. */
narrow?: boolean;
}
export interface EntityAdminProps<T extends { id: string }> {
title: string;
description?: string;
/** Cesta k API bez zaveru, napr. `/api/dashboard/settings/roles`. */
endpoint: string;
/** Odkud se bere seznam, kdyz je jiny nez `endpoint` (napr. vcetne vypnutych). */
listEndpoint?: string;
columns: Array<AdminColumn<T>>;
fields: Array<AdminField<T>>;
/** Sestavi telo requestu z hodnot formulare. */
toBody?: (values: Record<string, unknown>, entity: T | null) => Record<string, unknown>;
/** Naplni formular z existujiciho zaznamu. */
toForm?: (entity: T) => Record<string, unknown>;
/** false = zadny zapis, jen cteni. */
canWrite?: boolean;
/** false = zaznam nejde smazat (napr. systemova role). */
canDelete?: (entity: T) => boolean;
/** Doplnujici obsah nad tabulkou. */
children?: ReactNode;
/** Zavola se po kazdem uspesnem zapisu. */
onChanged?: () => void;
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function EntityAdmin<T extends { id: string }>({
title,
description,
endpoint,
listEndpoint,
columns,
fields,
toBody,
toForm,
canWrite = true,
canDelete,
children,
onChanged,
}: EntityAdminProps<T>) {
const [items, setItems] = useState<T[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<{ entity: T | null } | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
apiFetch<{ items: T[] }>(listEndpoint ?? endpoint)
.then((data) => setItems(data.items))
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Data se nepodařilo načíst.');
})
.finally(() => setLoading(false));
}, [endpoint, listEndpoint]);
useEffect(load, [load]);
async function remove(entity: T) {
try {
await apiFetch(`${endpoint}/${entity.id}`, { method: 'DELETE' });
load();
onChanged?.();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
}
}
return (
<section className="space-y-4">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-white">{title}</h2>
{description && <p className="mt-0.5 max-w-2xl text-sm text-white/45">{description}</p>}
</div>
{canWrite && (
<Button size="sm" onClick={() => setEditing({ entity: null })}>
<Plus className="size-4" />
Přidat
</Button>
)}
</header>
{children}
<DataState loading={loading} error={error} onRetry={load} empty={(items?.length ?? 0) === 0}>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
{columns.map((column) => (
<th key={column.label} className="px-4 py-2.5 font-semibold">
{column.label}
</th>
))}
{canWrite && <th className="px-4 py-2.5" />}
</tr>
</thead>
<tbody>
{(items ?? []).map((entity) => (
<tr key={entity.id} className="border-b border-ink-600/30 last:border-0">
{columns.map((column) => (
<td
key={column.label}
className={cn('px-4 py-2.5 align-top text-white/75', column.narrow && 'w-24')}
>
{column.render(entity)}
</td>
))}
{canWrite && (
<td className="px-4 py-2 text-right whitespace-nowrap">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing({ entity })}
aria-label="Upravit"
>
<Pencil className="size-3.5" />
</Button>
{(canDelete?.(entity) ?? true) && (
<Button
size="sm"
variant="ghost"
onClick={() => remove(entity)}
aria-label="Smazat"
>
<Trash2 className="size-3.5" />
</Button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</DataState>
{editing && (
<EntityForm
title={editing.entity ? `Upravit: ${title}` : `Nový záznam: ${title}`}
endpoint={endpoint}
entity={editing.entity}
fields={fields}
toBody={toBody}
toForm={toForm}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
load();
onChanged?.();
}}
/>
)}
</section>
);
}
// -------------------------------------------------------------------- formular
function EntityForm<T extends { id: string }>({
title,
endpoint,
entity,
fields,
toBody,
toForm,
onClose,
onSaved,
}: {
title: string;
endpoint: string;
entity: T | null;
fields: Array<AdminField<T>>;
toBody?: (values: Record<string, unknown>, entity: T | null) => Record<string, unknown>;
toForm?: (entity: T) => Record<string, unknown>;
onClose: () => void;
onSaved: () => void;
}) {
const visible = useMemo(
() => fields.filter((field) => (field.visible ? field.visible(entity) : true)),
[fields, entity],
);
const [values, setValues] = useState<Record<string, unknown>>(() => {
if (entity) return toForm ? toForm(entity) : (entity as unknown as Record<string, unknown>);
const initial: Record<string, unknown> = {};
for (const field of fields) {
initial[field.name] = field.initial ?? defaultFor(field.kind);
}
return initial;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
function set(name: string, value: unknown) {
setValues((previous) => ({ ...previous, [name]: value }));
}
async function submit() {
setSaving(true);
setError(null);
setIssues([]);
// Pole jen pro zakladani se pri uprave neposilaji, jinak by prazdne heslo
// prepsalo to stavajici.
const payload = toBody
? toBody(values, entity)
: Object.fromEntries(
visible
.filter((field) => !(entity && field.createOnly))
.map((field) => [field.name, values[field.name]]),
);
try {
await apiFetch(entity ? `${endpoint}/${entity.id}` : endpoint, {
method: entity ? 'PATCH' : 'POST',
body: payload,
});
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
return (
<Modal open onClose={onClose} title={title}>
<form
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="space-y-4 p-5"
>
{visible
.filter((field) => !(entity && field.createOnly))
.map((field) => (
<FieldInput
key={field.name}
field={field}
value={values[field.name]}
onChange={(value) => set(field.name, value)}
/>
))}
{error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="flex items-start gap-2 text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
{issues.length > 0 && (
<ul className="mt-1.5 ml-6 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Ukládám...' : entity ? 'Uložit' : 'Založit'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
</div>
</form>
</Modal>
);
}
function defaultFor(kind: FieldKind): unknown {
if (kind === 'checkbox') return false;
if (kind === 'multiselect' || kind === 'tags') return [];
if (kind === 'number') return 0;
if (kind === 'json') return '';
return '';
}
/** Jedno pole podle druhu. Novy druh se pridava jen tady. */
function FieldInput<T>({
field,
value,
onChange,
}: {
field: AdminField<T>;
value: unknown;
onChange: (value: unknown) => void;
}) {
const label = (
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
);
if (field.kind === 'checkbox') {
return (
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={value === true}
onChange={(event) => onChange(event.target.checked)}
className="size-4"
/>
{field.label}
{field.hint && <span className="text-xs text-white/35">{field.hint}</span>}
</label>
);
}
if (field.kind === 'multiselect') {
const selected = new Set(Array.isArray(value) ? (value as string[]) : []);
// Volby se sdruzuji do skupin, jinak je seznam dvaceti prav neprehledny.
const groups = new Map<string, typeof field.options>();
for (const option of field.options ?? []) {
const key = option.group ?? '';
groups.set(key, [...(groups.get(key) ?? []), option]);
}
return (
<div>
{label}
<div className="max-h-72 space-y-3 overflow-y-auto rounded-lg border border-ink-600/70 bg-ink-950/40 p-3">
{[...groups.entries()].map(([group, options]) => (
<div key={group}>
{group && (
<p className="mb-1 text-xs font-semibold tracking-wide text-white/40 uppercase">
{group}
</p>
)}
<div className="space-y-1">
{(options ?? []).map((option) => (
<label
key={option.value}
className="flex items-start gap-2 text-sm text-white/70"
>
<input
type="checkbox"
checked={selected.has(option.value)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(option.value);
else next.delete(option.value);
onChange([...next]);
}}
className="mt-0.5 size-3.5 shrink-0"
/>
{option.label}
</label>
))}
</div>
</div>
))}
</div>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'tags') {
const tags = Array.isArray(value) ? (value as string[]) : [];
return (
<div>
{label}
<input
value={tags.join(', ')}
onChange={(event) =>
onChange(
event.target.value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
)
}
placeholder="oddělte čárkou"
className={inputClass}
/>
{tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'select') {
return (
<label className="block">
{label}
<select
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
className={inputClass}
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
if (field.kind === 'textarea' || field.kind === 'json') {
const isJson = field.kind === 'json';
const text = typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2);
const broken = isJson && text.trim() !== '' && !isValidJson(text);
return (
<label className="block">
{label}
<textarea
rows={isJson ? 8 : 3}
spellCheck={!isJson}
value={text}
onChange={(event) => onChange(event.target.value)}
className={cn(
inputClass,
'resize-y',
isJson && 'font-mono text-xs',
broken && 'border-warn-400/60',
)}
/>
{broken && <span className="mt-1 block text-xs text-warn-400">Tohle není platný JSON.</span>}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
return (
<label className="block">
{label}
<input
type={field.kind === 'number' ? 'number' : field.name.includes('password') ? 'password' : 'text'}
autoComplete={field.name.includes('password') ? 'new-password' : 'off'}
value={value === null || value === undefined ? '' : String(value)}
onChange={(event) =>
onChange(field.kind === 'number' ? Number(event.target.value) : event.target.value)
}
className={inputClass}
/>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
function isValidJson(text: string): boolean {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/** Prevod textu z pole `json` na hodnotu. Rozbity JSON necha puvodni text. */
export function parseJsonField(value: unknown, fallback: unknown): unknown {
if (typeof value !== 'string') return value ?? fallback;
if (value.trim() === '') return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
@@ -0,0 +1,503 @@
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
/**
* Tlacitka akci na ticketu.
*
* Seznam prichazi ze serveru **uz vyfiltrovany** podle typu, tagu, podminek
* a prav. Klient si nepocita, co ukazat - jinak by se to pocitalo na dvou
* mistech a jednou se rozejde.
*/
interface ActionFormField {
id: string;
label: string;
kind: 'text' | 'longtext' | 'choice';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
interface TicketActionItem {
id: string;
label: string;
icon: string;
style: 'primary' | 'default' | 'danger';
confirm: string | null;
form: ActionFormField[];
}
interface RunResult {
ok: boolean;
summary: string;
detail: string | null;
durationMs: number;
}
/** Co z ticketu tahle komponenta potrebuje. Zamerne uzsi nez cely `Ticket`. */
export interface ActionableTicket {
id: string;
typeId: string | null;
tags: string[];
fields: Record<string, string | number | boolean | null>;
}
/**
* Typ ticketu tak, jak ho vraci nastaveni. Jen to, co je tady potreba.
*
* `type` je stejna sada jako u parametru kroku (`FieldType` na serveru),
* aby se na jedno pole dalo odkazovat z podminky i ze sablony.
*/
interface TicketTypeOption {
id: string;
name: string;
fields: Array<{
id: string;
key: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}>;
}
export function TicketActions({
ticket,
onDone,
}: {
ticket: ActionableTicket;
onDone: () => void;
}) {
const ticketId = ticket.id;
const [items, setItems] = useState<TicketActionItem[]>([]);
const [pending, setPending] = useState<TicketActionItem | null>(null);
const [result, setResult] = useState<{ action: string; value: RunResult } | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
apiFetch<{ items: TicketActionItem[] }>(`/api/dashboard/tickets/${ticketId}/actions`)
.then((data) => setItems(data.items))
.catch((err: unknown) => {
// Chybejici akce nesmi zhasnout detail ticketu.
console.warn('[actions] seznam se nepodarilo nacist:', err);
setItems([]);
});
}, [ticketId]);
useEffect(load, [load]);
async function run(action: TicketActionItem, form: Record<string, string>) {
setError(null);
setPending(null);
try {
const value = await apiFetch<RunResult>(
`/api/dashboard/tickets/${ticketId}/actions/${action.id}`,
{ method: 'POST', body: { form } },
);
setResult({ action: action.label, value });
// Akce mohla zmenit ticket i jeho log, proto obnova detailu.
onDone();
load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Akci se nepodařilo spustit.');
}
}
const hasActionBlock = items.length > 0 || result !== null || error !== null;
return (
<div className="glass space-y-4 rounded-card p-4">
<TicketMeta
ticket={ticket}
onChanged={() => {
onDone();
// Zmena typu nebo tagu meni i seznam akci, proto se nacte znovu.
load();
}}
/>
{hasActionBlock && (
<div className="space-y-3 border-t border-ink-600/50 pt-4">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">Akce</p>
<div className="flex flex-wrap gap-2">
{items.map((action) => {
const Icon = serviceIcon(action.icon);
return (
<Button
key={action.id}
size="sm"
variant={action.style === 'primary' ? 'primary' : 'secondary'}
onClick={() => {
// Bez potvrzeni a bez formulare se spusti hned.
if (!action.confirm && action.form.length === 0) void run(action, {});
else setPending(action);
}}
>
<Icon className="size-3.5" />
{action.label}
</Button>
);
})}
</div>
{error && <p className="text-sm text-danger-400">{error}</p>}
{result && (
<div
className={cn(
'rounded-lg border p-3 text-sm',
result.value.ok
? 'border-ok-500/40 bg-ok-500/8'
: 'border-danger-500/40 bg-danger-500/8',
)}
>
<p
className={cn(
'flex items-start gap-2',
result.value.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{result.value.ok ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
)}
<span>
{result.action}: {result.value.summary}
<span className="mt-0.5 block text-xs text-white/40">
{result.value.durationMs} ms. Celý průběh je v logu ticketu níž.
</span>
</span>
</p>
{result.value.detail && (
<pre className="mt-2 max-h-48 overflow-auto rounded bg-ink-950/70 p-2 font-mono text-xs whitespace-pre-wrap text-white/60">
{result.value.detail}
</pre>
)}
</div>
)}
</div>
)}
{pending && (
<ActionDialog
action={pending}
onClose={() => setPending(null)}
onRun={(form) => void run(pending, form)}
/>
)}
</div>
);
}
/**
* Typ a tagy ticketu.
*
* Je to tady, a ne v postrannim panelu, protoze **na typu zavisi, ktera akce
* se ukaze**. Kdyz je oboji vedle sebe, je souvislost videt.
*/
function TicketMeta({
ticket,
onChanged,
}: {
ticket: ActionableTicket;
onChanged: () => void;
}) {
const [types, setTypes] = useState<TicketTypeOption[]>([]);
const [tags, setTags] = useState(ticket.tags.join(', '));
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
apiFetch<{ items: TicketTypeOption[] }>('/api/dashboard/settings/ticket-types')
.then((data) => setTypes(data.items))
// Bez prava na typy se jen nenabidnou, detail ticketu tim nespadne.
.catch(() => setTypes([]));
}, []);
// Po ulozeni se detail znovu nacte, textove pole musi jit za nim.
useEffect(() => setTags(ticket.tags.join(', ')), [ticket.tags]);
async function send(path: string, body: unknown) {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/dashboard/tickets/${ticket.id}/${path}`, { method: 'POST', body });
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Změnu se nepodařilo uložit.');
} finally {
setBusy(false);
}
}
const type = types.find((item) => item.id === ticket.typeId) ?? null;
return (
<div className="space-y-2">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">Typ a tagy</p>
<div className="flex flex-wrap items-center gap-2">
<select
value={ticket.typeId ?? ''}
disabled={busy || types.length === 0}
onChange={(event) =>
void send('type', { typeId: event.target.value === '' ? null : event.target.value })
}
className={metaFieldClass}
>
<option value="" className="bg-ink-850">
Bez typu
</option>
{types.map((item) => (
<option key={item.id} value={item.id} className="bg-ink-850">
{item.name}
</option>
))}
</select>
<input
value={tags}
disabled={busy}
onChange={(event) => setTags(event.target.value)}
onBlur={() => {
const next = tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
// Bez zmeny se neposila nic, aby kazde opusteni pole nedelalo zapis.
if (next.join(',') !== ticket.tags.join(',')) void send('tags', { tags: next });
}}
placeholder="tagy oddělené čárkou"
className={cn(metaFieldClass, 'min-w-48 flex-1')}
/>
</div>
{ticket.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{ticket.tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
{type && type.fields.some((field) => field.type !== 'object' && field.type !== 'list') && (
<TypeFields
type={type}
values={ticket.fields}
busy={busy}
onSave={(fields) => void send('type', { typeId: type.id, fields })}
/>
)}
{error && <p className="text-xs text-danger-400">{error}</p>}
</div>
);
}
const metaFieldClass =
'rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1 text-sm text-white focus:border-brand-400/70 focus:outline-none';
/**
* Vlastni pole zvoleneho typu ticketu.
*
* Uklada se najednou, ne po jednom poli - jinak by kazde pismeno bylo zapis
* a v logu ticketu by vznikla stovka zaznamu o zmene typu.
*/
function TypeFields({
type,
values,
busy,
onSave,
}: {
type: TicketTypeOption;
values: Record<string, string | number | boolean | null>;
busy: boolean;
onSave: (fields: Record<string, string | number | boolean | null>) => void;
}) {
const [draft, setDraft] = useState(values);
// Po ulozeni prijde novy ticket, rozepsany stav se musi srovnat se serverem.
useEffect(() => setDraft(values), [values]);
/**
* Objekt a seznam sem plni automatizace, rucne se nevyplnuji - textarea
* s JSONem by tady byla past, ne pohodli.
*/
const editable = type.fields.filter((field) => field.type !== 'object' && field.type !== 'list');
const dirty = editable.some((field) => (draft[field.key] ?? '') !== (values[field.key] ?? ''));
const missing = editable.filter(
(field) => field.required && String(draft[field.key] ?? '').trim() === '',
);
function set(key: string, value: string | number | boolean | null) {
setDraft((prev) => ({ ...prev, [key]: value }));
}
return (
<div className="space-y-2 rounded-lg border border-ink-600/50 bg-ink-900/40 p-3">
<div className="grid gap-2 sm:grid-cols-2">
{editable.map((field) => {
const value = draft[field.key];
const text = value === null || value === undefined ? '' : String(value);
return (
<label key={field.id} className="block">
<span className="mb-1 flex items-center gap-1.5 text-xs text-white/55">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
{field.type === 'boolean' ? (
<input
type="checkbox"
checked={value === true}
disabled={busy}
onChange={(event) => set(field.key, event.target.checked)}
className="size-4 accent-brand-400"
/>
) : field.options && field.options.length > 0 ? (
<select
value={text}
disabled={busy}
onChange={(event) => set(field.key, event.target.value)}
className={cn(metaFieldClass, 'w-full')}
>
<option value="" className="bg-ink-850">
Nevyplněno
</option>
{field.options.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
) : (
<input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={text}
disabled={busy}
onChange={(event) =>
set(
field.key,
field.type === 'number'
? // Prazdne pole neni nula, ale nevyplneno.
event.target.value === ''
? null
: Number(event.target.value)
: event.target.value,
)
}
className={cn(metaFieldClass, 'w-full')}
/>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
})}
</div>
{dirty && (
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || missing.length > 0} onClick={() => onSave(draft)}>
Uložit pole
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setDraft(values)}>
Zrušit změny
</Button>
{missing.length > 0 && (
<Badge tone="warn">Vyplňte: {missing.map((field) => field.label).join(', ')}</Badge>
)}
</div>
)}
</div>
);
}
function ActionDialog({
action,
onClose,
onRun,
}: {
action: TicketActionItem;
onClose: () => void;
onRun: (form: Record<string, string>) => void;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const missing = action.form.filter(
(field) => field.required && (values[field.id] ?? '').trim() === '',
);
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none';
return (
<Modal open onClose={onClose} title={action.label}>
<div className="space-y-4 p-5">
{action.confirm && <p className="text-sm text-white/70">{action.confirm}</p>}
{action.form.map((field) => (
<label key={field.id} className="block">
<span className="mb-1 flex items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
{field.kind === 'choice' ? (
<select
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={inputClass}
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
) : field.kind === 'longtext' ? (
<textarea
rows={3}
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={cn(inputClass, 'resize-y')}
/>
) : (
<input
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={inputClass}
/>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
))}
<div className="flex flex-wrap items-center gap-3 border-t border-ink-600/50 pt-4">
<Button size="sm" onClick={() => onRun(values)} disabled={missing.length > 0}>
Spustit
</Button>
<Button size="sm" variant="secondary" onClick={onClose}>
Zrušit
</Button>
{missing.length > 0 && (
<Badge tone="warn">Vyplňte: {missing.map((field) => field.label).join(', ')}</Badge>
)}
</div>
</div>
</Modal>
);
}
+915 -54
View File
@@ -1,79 +1,940 @@
import { Construction } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Access } from '@/types/dashboard';
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
/**
* Nastaveni portalu.
*
* Vsechny zalozky stoji na `EntityAdmin`, takze se tady pise jen to, co je
* u dane entity jine: sloupce tabulky a pole formulare. Nova entita znamena
* jeden zaznam v `tabs` nize.
*
* Slozitejsi struktury (pole typu ticketu, telo akce, zdroj widgetu) se upravuji
* jako JSON. Klikaci editor by u nich byl vetsi prace nez cely zbytek nastaveni
* a JSON je zatim citelnejsi nez deset vnorenych formularu.
*/
interface CatalogItem {
key: string;
label: string;
group?: string;
hint?: string;
platform?: boolean;
}
interface SettingsCatalog {
permissions: CatalogItem[];
modules: Array<{ key: string; label: string; required?: boolean; platform?: boolean }>;
defaultLimits: Record<string, number>;
platformAdmin: boolean;
}
interface NamedRecord {
id: string;
name?: string;
label?: string;
[key: string]: unknown;
}
export default function Settings() {
usePageMeta({ title: 'Nastavení — portál Automia' });
usePageMeta({ title: 'Nastavení - portál Automia' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const catalog = useApiQuery<SettingsCatalog>('/api/dashboard/settings/catalog');
const [tab, setTab] = useState('ucet');
const can = useMemo(() => {
const owned = new Set(access.data?.permissions ?? []);
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;
/**
* Zalozky. Kazda rika, cim je podminena - bez prava se ani nezobrazi,
* aby clovek neklikal do 403.
*/
const tabs = useMemo(() => {
const all: Array<{ key: string; label: string; show: boolean }> = [
{ key: 'ucet', label: 'Můj účet', show: true },
{ key: 'people', label: 'Řešitelé', show: can('people.manage') },
{ key: 'groups', label: 'Skupiny', show: can('group.manage') },
{ key: 'roles', label: 'Role a práva', show: can('role.manage') },
{ key: 'types', label: 'Typy ticketů', show: can('ticketType.manage') },
{ key: 'actions', label: 'Akce na ticketu', show: can('action.manage') },
{ key: 'widgets', label: 'Vlastní widgety', show: can('widget.manage') },
{ key: 'users', label: 'Uživatelé', show: platformAdmin },
{ key: 'tenants', label: 'Firmy', show: platformAdmin },
{ key: 'features', label: 'Záložky firmy', show: platformAdmin },
{ key: 'audit', label: 'Audit', show: platformAdmin },
];
return all.filter((item) => item.show);
}, [can, platformAdmin]);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
<p className="mt-1 text-sm text-white/50">Údaje o účtu a organizaci.</p>
<p className="mt-1 text-sm text-white/50">
Co která firma má, kdo co smí a co se dá na ticketu udělat.
</p>
</header>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Váš účet</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<Row label="Jméno" value={user?.name ?? '—'} />
<Row label="E-mail" value={user?.email ?? '—'} />
<Row
label="Přístup"
value={user?.platformAdmin ? 'Správce platformy' : 'Účet firmy'}
/>
<Row label="Firem" value={String(user?.memberships.length ?? 0)} />
</dl>
</section>
<div className="flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setTab(item.key)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
tab === item.key
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{item.label}
</button>
))}
</div>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Členství ve firmách</h2>
<p className="mt-1 text-sm text-white/45">
Role je vždy až uvnitř firmy. Ve dvou firmách můžete mít různá práva.
</p>
{access.data && access.data.tenants.length > 0 ? (
<ul className="mt-4 space-y-2">
{access.data.tenants.map((tenant) => {
const role = user?.memberships.find((m) => m.tenantId === tenant.id)?.role;
return (
<li
key={tenant.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3"
>
<span className="text-sm text-white/80">{tenant.name}</span>
<span className="text-xs text-white/45">
{role === 'admin' ? 'Správce firmy' : role === 'agent' ? 'Řešitel' : 'Přes platformu'}
</span>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-white/40">Účet nepatří do žádné firmy.</p>
)}
</section>
{tab === 'ucet' && <MyAccount />}
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
<Construction className="mx-auto size-6 text-white/30" />
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-white/50">
Změna hesla, dvoufaktorové ověření, správa uživatelů organizace, notifikace a API klíče.
Rozsah domluvíme při stavbě dashboardu.
</p>
</section>
{tab === 'people' && (
<EntityAdmin
title="Řešitelé"
description="Lidé, na které jde přehodit ticket. Nemusí mít přístup do portálu, spojka je e-mail."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
columns={[
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
{ label: 'Kapacita', narrow: true, render: (person: NamedRecord) => String(person.capacity ?? '') },
{
label: 'Stav',
narrow: true,
render: (person: NamedRecord) =>
person.enabled === false ? <Badge tone="warn">vypnutý</Badge> : <Badge tone="ok">aktivní</Badge>,
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{ name: 'role', label: 'Zaměření', kind: 'text', hint: 'Jen popisek, nic nerozhoduje.' },
{
name: 'capacity',
label: 'Kapacita',
kind: 'number',
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'groups' && (
<EntityAdmin
title="Skupiny řešitelů"
description="Aby šlo říct: tohle je pro účetní, bez řešení, kdo z nich má dovolenou."
endpoint="/api/dashboard/settings/groups"
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: 'tags',
hint: 'ID řešitelů oddělená čárkou. Najdete je v záložce Řešitelé.',
},
]}
/>
)}
{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 === 'actions' && (
<EntityAdmin
title="Akce na ticketu"
description="Tlačítko na ticketu. Váže se na typ nebo tag a spouští operaci služby, strom nebo skript."
endpoint="/api/dashboard/settings/actions"
listEndpoint="/api/dashboard/settings/actions-overview"
columns={[
{ label: 'Akce', render: (action: NamedRecord) => String(action.label ?? '') },
{
label: 'Na čem',
render: (action: NamedRecord) => {
const types = (action.ticketTypeIds as string[] | undefined) ?? [];
const tags = (action.tags as string[] | undefined) ?? [];
return [types.length > 0 ? `typy: ${types.join(', ')}` : null, tags.length > 0 ? `tagy: ${tags.join(', ')}` : null]
.filter(Boolean)
.join(' | ');
},
},
{
label: 'Tělo',
narrow: true,
render: (action: NamedRecord) => {
const body = action.body as { kind?: string } | undefined;
return <code>{body?.kind ?? '?'}</code>;
},
},
{
label: 'Stav',
narrow: true,
render: (action: NamedRecord) =>
action.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">zapnutá</Badge>,
},
]}
fields={actionFields}
toBody={(values, _entity) => ({
label: values.label,
icon: values.icon,
style: values.style || 'default',
order: values.order,
ticketTypeIds: values.ticketTypeIds,
tags: values.tags,
confirm: values.confirm === '' ? null : values.confirm,
visibleWhen: parseJsonField(values.visibleWhen, []),
form: parseJsonField(values.form, []),
body: parseJsonField(values.body, { kind: 'tree', steps: [] }),
...(values.enabled !== undefined ? { enabled: values.enabled } : {}),
})}
toForm={(action: NamedRecord) => ({
...action,
confirm: action.confirm ?? '',
visibleWhen: JSON.stringify(action.visibleWhen ?? [], null, 2),
form: JSON.stringify(action.form ?? [], null, 2),
body: JSON.stringify(action.body ?? {}, null, 2),
})}
/>
)}
{tab === 'widgets' && (
<EntityAdmin
title="Vlastní widgety"
description="Dlaždice na přehled. Filtr je tentýž, který umí seznam ticketů."
endpoint="/api/dashboard/settings/widgets"
listEndpoint="/api/dashboard/settings/widgets-overview"
columns={[
{ label: 'Název', render: (widget: NamedRecord) => String(widget.name ?? '') },
{ label: 'Vykreslení', narrow: true, render: (widget: NamedRecord) => <code>{String(widget.render ?? '')}</code> },
{
label: 'Zdroj',
render: (widget: NamedRecord) => {
const source = widget.source as { kind?: string; groupBy?: string } | undefined;
return `${source?.kind ?? '?'}${source?.groupBy ? ` podle ${source.groupBy}` : ''}`;
},
},
{ label: 'Šířka', narrow: true, render: (widget: NamedRecord) => String(widget.size ?? '') },
]}
fields={widgetFields}
toBody={(values) => ({
name: values.name,
description: values.description,
render: values.render,
size: values.size,
...(values.target ? { target: Number(values.target) } : {}),
source: parseJsonField(values.source, { kind: 'ticketCount', filter: {} }),
})}
toForm={(widget: NamedRecord) => ({
...widget,
source: JSON.stringify(widget.source ?? {}, null, 2),
})}
/>
)}
{tab === 'users' && <UsersAdmin />}
{tab === 'tenants' && (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
{ label: 'Poznámka', render: (tenant: NamedRecord) => String(tenant.note ?? '') },
{
label: 'Stav',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">aktivní</Badge>,
},
]}
fields={[
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
{ name: 'note', label: 'Poznámka', kind: 'textarea', hint: 'Pro nás, klient ji nevidí.' },
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'features' && <FeaturesAdmin catalog={catalog.data} />}
{tab === 'audit' && <AuditView />}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
// ------------------------------------------------------------------ moje ucet
function MyAccount() {
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function changePassword() {
setError(null);
setMessage(null);
try {
await apiFetch(`/api/dashboard/settings/users/${user!.id}/password`, {
method: 'PATCH',
body: { password },
});
setPassword('');
setMessage('Heslo změněno.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Heslo se nepodařilo změnit.');
}
}
return (
<div className="rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3">
<dt className="text-xs text-white/35">{label}</dt>
<dd className="mt-0.5 text-sm text-white/80">{value}</dd>
<div className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">{user?.name}</h2>
<p className="text-sm text-white/45">{user?.email}</p>
<dl className="mt-4 grid gap-3 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<div>
<dt className="text-xs tracking-wide text-white/40 uppercase">Firmy</dt>
<dd className="mt-0.5 text-sm text-white/75">
{(access.data?.tenants ?? []).map((tenant) => tenant.name).join(', ') || 'žádná'}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-white/40 uppercase">Práv</dt>
<dd className="mt-0.5 text-sm text-white/75">
{access.data?.permissions.length ?? 0}
{user?.platformAdmin && ' (správce platformy)'}
</dd>
</div>
</dl>
{access.data && access.data.permissions.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-white/45 hover:text-white">
Zobrazit moje práva
</summary>
<div className="mt-2 flex flex-wrap gap-1.5">
{access.data.permissions.map((permission) => (
<code key={permission} className="rounded bg-ink-950/60 px-1.5 py-0.5 text-xs text-white/60">
{permission}
</code>
))}
</div>
</details>
)}
</div>
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Změna hesla</h3>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="block flex-1">
<span className="mb-1 block text-sm text-white/70">Nové heslo</span>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
/>
</label>
<Button size="sm" onClick={changePassword} disabled={password.length < 8}>
Změnit
</Button>
</div>
{message && <p className="mt-2 text-sm text-ok-400">{message}</p>}
{error && <p className="mt-2 text-sm text-danger-400">{error}</p>}
<p className="mt-2 text-xs text-white/35">Aspoň 8 znaků.</p>
</div>
{user?.platformAdmin && <AccountSwitch />}
</div>
);
}
/**
* Prepnuti na jiny ucet.
*
* Vychozi je **jen pro cteni** - podivat se, co klient vidi, je castejsi nez
* za nej neco menit, a nechtene zapisy pod cizim jmenem jsou to nejhorsi,
* co se tady muze stat. Zapis se musi zapnout vedome a je v auditu.
*/
function AccountSwitch() {
const { startImpersonation, impersonation } = useAuth();
const [candidates, setCandidates] = useState<Array<{ id: string; name: string; email: string }>>(
[],
);
const [userId, setUserId] = useState('');
const [allowWrites, setAllowWrites] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string; email: string }> }>(
'/api/admin/impersonate/candidates',
)
.then((data) => setCandidates(data.items))
.catch(() => setCandidates([]));
}, []);
if (impersonation) {
return (
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Přepnutí účtu</h3>
<p className="mt-2 text-sm text-white/50">
Právě jste přepnutí na {impersonation.userName}. Zpět na svůj účet se vrátíte pruhem
nahoře.
</p>
</div>
);
}
return (
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Přepnutí na jiný účet</h3>
<p className="mt-1 text-sm text-white/45">
Uvidíte portál jeho očima, včetně záložek a práv. Zaznamená se to do auditu.
</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="block flex-1">
<span className="mb-1 block text-sm text-white/70">Účet</span>
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{candidates.map((candidate) => (
<option key={candidate.id} value={candidate.id} className="bg-ink-850">
{candidate.name} ({candidate.email})
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-white/70">
<input
type="checkbox"
checked={allowWrites}
onChange={(event) => setAllowWrites(event.target.checked)}
className="size-4 accent-brand-400"
/>
Povolit i zápis
</label>
<Button
size="sm"
disabled={userId === ''}
onClick={() => {
setError(null);
startImpersonation(userId, allowWrites).catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Přepnutí se nepodařilo.');
});
}}
>
Přepnout
</Button>
</div>
{error && <p className="mt-2 text-sm text-danger-400">{error}</p>}
{candidates.length === 0 && (
<p className="mt-2 text-xs text-white/35">Žádný účet k přepnutí není.</p>
)}
</div>
);
}
// ----------------------------------------------------------------- uzivatele
function UsersAdmin() {
const [tenants, setTenants] = useState<Array<{ id: string; name: string }>>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>('/api/dashboard/settings/tenants')
.then((data) => setTenants(data.items))
.catch(() => setTenants([]));
}, []);
return (
<EntityAdmin
title="Uživatelé"
description="Kdo se přihlásí do portálu. Členství určuje, do kterých firem vidí a s jakými rolemi."
endpoint="/api/dashboard/settings/users"
listEndpoint="/api/dashboard/settings/users-overview"
columns={[
{ label: 'Jméno', render: (user: NamedRecord) => String(user.name ?? '') },
{ label: 'E-mail', render: (user: NamedRecord) => String(user.email ?? '') },
{
label: 'Členství',
render: (user: NamedRecord) => {
const memberships = (user.memberships as Array<{ tenantId: string; roleIds: string[] }>) ?? [];
return memberships
.map((membership) => {
const name = tenants.find((tenant) => tenant.id === membership.tenantId)?.name ?? membership.tenantId;
return `${name}: ${membership.roleIds.join(', ')}`;
})
.join(' | ');
},
},
{
label: 'Stav',
narrow: true,
render: (user: NamedRecord) => (
<span className="flex flex-wrap gap-1">
{user.platformAdmin === true && <Badge tone="warn">platforma</Badge>}
{user.enabled === false && <Badge tone="warn">vypnutý</Badge>}
</span>
),
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{
name: 'password',
label: 'Heslo',
kind: 'text',
required: true,
createOnly: true,
hint: 'Aspoň 8 znaků. Změna hesla je pak zvlášť.',
},
{
name: 'platformAdmin',
label: 'Správce platformy',
kind: 'checkbox',
hint: 'Vidí napříč všemi firmami.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
name: 'memberships',
label: 'Členství',
kind: 'json',
initial: '[]',
hint: 'Seznam: tenantId a roleIds. Například [{"tenantId":"tnt_automia","roleIds":["agent"]}]',
},
]}
toBody={(values, entity) => ({
name: values.name,
email: values.email,
...(entity ? {} : { password: values.password }),
platformAdmin: values.platformAdmin === true,
enabled: values.enabled !== false,
memberships: parseJsonField(values.memberships, []),
})}
toForm={(user: NamedRecord) => ({
...user,
memberships: JSON.stringify(user.memberships ?? [], null, 2),
})}
/>
);
}
// ------------------------------------------------------------ zalozky firmy
function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
const [features, setFeatures] = useState<{
modules: string[];
limits: Record<string, number>;
serviceIds: string[];
} | null>(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = () => {
apiFetch<{ features: typeof features }>('/api/dashboard/settings/features')
.then((data) => setFeatures(data.features))
.catch((err: unknown) => setError(err instanceof Error ? err.message : 'Nepodařilo se načíst.'));
};
useEffect(load, []);
async function save() {
if (!features) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await apiFetch('/api/dashboard/settings/features', { method: 'PUT', body: features });
setMessage('Uloženo. Záložky se projeví po obnovení stránky.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
} finally {
setSaving(false);
}
}
if (!features || !catalog) return <p className="text-sm text-white/45">Načítám...</p>;
return (
<div className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Záložky firmy</h2>
<p className="mt-0.5 text-sm text-white/45">
Co má firma zapnuté. Vypnutý modul neexistuje ani pro jejího správce, protože ho
nemá. Kdo z jejich lidí to smí, řeší role.
</p>
<div className="mt-4 space-y-1.5 border-t border-ink-600/50 pt-4">
{catalog.modules
.filter((module) => !module.platform)
.map((module) => (
<label key={module.key} className="flex items-center gap-2 text-sm text-white/75">
<input
type="checkbox"
disabled={module.required}
checked={features.modules.includes(module.key)}
onChange={(event) =>
setFeatures({
...features,
modules: event.target.checked
? [...features.modules, module.key]
: features.modules.filter((key) => key !== module.key),
})
}
className="size-4"
/>
{module.label}
{module.required && <span className="text-xs text-white/35">povinné</span>}
</label>
))}
</div>
</div>
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Limity</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-4">
{Object.entries(features.limits).map(([key, value]) => (
<label key={key} className="block">
<span className="mb-1 block text-xs text-white/60">{key}</span>
<input
type="number"
value={value}
onChange={(event) =>
setFeatures({
...features,
limits: { ...features.limits, [key]: Number(event.target.value) },
})
}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
/>
</label>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" onClick={save} disabled={saving}>
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
{message && <span className="text-sm text-ok-400">{message}</span>}
{error && <span className="text-sm text-danger-400">{error}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------- audit
interface AuditEntry {
id: string;
createdAt: string;
userEmail: string;
actedBy: string | null;
action: string;
target: string | null;
result: 'ok' | 'denied';
}
function AuditView() {
const [onlyDenied, setOnlyDenied] = useState(false);
const query = useApiQuery<{ items: AuditEntry[] }>(
`/api/admin/audit?limit=200${onlyDenied ? '&result=denied' : ''}`,
);
return (
<div className="space-y-4">
<div>
<h2 className="font-semibold text-white">Audit</h2>
<p className="mt-0.5 text-sm text-white/45">
Kdo co udělal. Zapisují se i odepření, jinak by pokusy o cizí firmu nikde
nezůstaly.
</p>
</div>
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={onlyDenied}
onChange={(event) => setOnlyDenied(event.target.checked)}
className="size-4"
/>
Jen odepření
</label>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
<th className="px-4 py-2.5 font-semibold">Kdy</th>
<th className="px-4 py-2.5 font-semibold">Kdo</th>
<th className="px-4 py-2.5 font-semibold">Co</th>
<th className="px-4 py-2.5 font-semibold">Nad čím</th>
<th className="px-4 py-2.5 font-semibold">Výsledek</th>
</tr>
</thead>
<tbody>
{(query.data?.items ?? []).map((entry) => (
<tr key={entry.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2 whitespace-nowrap text-white/50">
{new Date(entry.createdAt).toLocaleString('cs')}
</td>
<td className="px-4 py-2 text-white/75">
{entry.userEmail}
{entry.actedBy && (
<span className="block text-xs text-warn-400">za něj: {entry.actedBy}</span>
)}
</td>
<td className="px-4 py-2">
<code className="text-white/70">{entry.action}</code>
</td>
<td className="px-4 py-2 text-white/50">{entry.target ?? ''}</td>
<td className="px-4 py-2">
{entry.result === 'denied' ? (
<Badge tone="danger">odepřeno</Badge>
) : (
<Badge tone="ok">ok</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{(query.data?.items.length ?? 0) === 0 && (
<p className="py-6 text-center text-sm text-white/40">Žádné záznamy.</p>
)}
</div>
);
}
// ---------------------------------------------------- definice poli formularu
const actionFields: Array<AdminField<NamedRecord>> = [
{ name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true },
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play' },
{
name: 'style',
label: 'Vzhled',
kind: 'select',
initial: 'default',
options: [
{ value: 'primary', label: 'Hlavní' },
{ value: 'default', label: 'Běžný' },
{ value: 'danger', label: 'Nebezpečný' },
],
},
{ name: 'order', label: 'Pořadí', kind: 'number', initial: 10 },
{
name: 'ticketTypeIds',
label: 'Typy ticketu',
kind: 'tags',
hint: 'ID typů oddělená čárkou. Za typem stojí pole, takže akce z nich může čerpat.',
},
{
name: 'tags',
label: 'Tagy',
kind: 'tags',
hint: 'Za tagem nestojí žádná pole, takže akce umí jen vestavěná pole ticketu.',
},
{ name: 'confirm', label: 'Text potvrzení', kind: 'text', hint: 'Prázdné = spustí se hned.' },
{
name: 'visibleWhen',
label: 'Kdy se tlačítko ukáže',
kind: 'json',
initial: '[]',
hint: 'Podmínky: fieldId, operator, value. Například [{"fieldId":"ticket.status","operator":"neq","value":"resolved"}]',
},
{
name: 'form',
label: 'Doptávací formulář',
kind: 'json',
initial: '[]',
hint: 'Na co se zeptat před spuštěním: id, label, kind, required.',
},
{
name: 'body',
label: 'Co akce dělá',
kind: 'json',
initial: '{\n "kind": "operation",\n "serviceId": "idoklad",\n "operationId": "create-issued-invoice",\n "connectorId": null,\n "inputs": {}\n}',
hint: 'kind: operation (jeden krok služby), tree (vlastní strom) nebo script.',
},
{ name: 'enabled', label: 'Zapnutá', kind: 'checkbox', initial: true },
];
const widgetFields: Array<AdminField<NamedRecord>> = [
{ name: 'name', label: 'Název', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'render',
label: 'Vykreslení',
kind: 'select',
initial: 'stat',
options: [
{ value: 'stat', label: 'Jedno číslo' },
{ value: 'table', label: 'Tabulka' },
{ value: 'list', label: 'Seznam' },
{ value: 'chart', label: 'Graf' },
{ value: 'gauge', label: 'Ukazatel' },
],
},
{
name: 'size',
label: 'Šířka',
kind: 'select',
initial: 'third',
options: [
{ value: 'third', label: 'Třetina' },
{ value: 'half', label: 'Polovina' },
{ value: 'full', label: 'Celá šířka' },
],
hint: 'Číslo a ukazatel umí třetinu a polovinu, ostatní polovinu a celou šířku.',
},
{ name: 'target', label: 'Cílová hodnota', kind: 'number', hint: 'Jen u ukazatele.' },
{
name: 'source',
label: 'Zdroj dat',
kind: 'json',
initial: '{\n "kind": "ticketCount",\n "filter": { "status": ["new", "open", "waiting"] }\n}',
hint: 'kind: ticketCount, ticketList, ticketSeries nebo workload. U ticketCount lze groupBy.',
},
];
+44
View File
@@ -16,6 +16,7 @@ import {
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketActions } from '@/components/dashboard/TicketActions';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -133,6 +134,12 @@ export default function TicketDetail() {
</p>
)}
{/*
Akce prichazi ze serveru uz vyfiltrovane podle typu, tagu,
podminek a prav. Klient si nepocita, co ukazat.
*/}
<TicketActions ticket={ticket.data} onDone={ticket.reload} />
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<div className="space-y-6">
<section className="glass rounded-card p-5 sm:p-6">
@@ -243,6 +250,43 @@ export default function TicketDetail() {
))}
</select>
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt
"u ucetnich" a zaroven u konkretniho cloveka z nich.
*/}
{(people.data?.groups ?? []).length > 0 && (
<>
<label
htmlFor="ticket-group"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Skupina
</label>
<select
id="ticket-group"
value={ticket.data.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate(
'/group',
{ groupId: event.target.value === '' ? null : event.target.value },
'Přiřazení skupině se nepodařilo.',
)
}
className={cn(selectClass, 'mt-2 w-full')}
>
<option value="" className="bg-ink-850">
Bez skupiny
</option>
{(people.data?.groups ?? []).map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
</>
)}
<label
htmlFor="ticket-status"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
+21 -1
View File
@@ -26,12 +26,22 @@ export interface Tenant {
export type TicketScope = 'all' | 'tenant' | 'mine';
/** Co uzivatel smi. Klient si to nesmi dovozovat sam. */
export interface NavEntry {
key: string;
label: string;
}
export interface Access {
scopes: TicketScope[];
tenants: Tenant[];
tenants: Array<{ id: string; name: string }>;
defaultTenantId: string | null;
canAssignOthers: boolean;
personId: string | null;
/** Efektivni prava. Klient podle nich kresli tlacitka, nedovozuje si je sam. */
permissions: string[];
/** Zalozky ze serveru: prunik toho, co firma ma, a toho, na co ma pravo. */
nav: NavEntry[];
platformAdmin: boolean;
}
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
@@ -68,8 +78,16 @@ export interface Ticket {
priority: TicketPriority;
/** Kdo ma ticket u sebe. null = ceka ve fronte. */
assignee: { id: string; name: string } | null;
/** Skupina resitelu, ktera ma ticket u sebe. */
assigneeGroupId: string | null;
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
/** Typ ticketu. Rozhoduje o vlastnich polich a o tom, ktere akce se ukazou. */
typeId: string | null;
/** Hodnoty vlastnich poli daneho typu. */
fields: Record<string, string | number | boolean | null>;
/** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */
tags: string[];
createdAt: string;
updatedAt: string;
}
@@ -108,6 +126,8 @@ export interface TicketListResponse {
export interface PeopleResponse {
items: Person[];
/** Skupiny resitelu te same firmy. Chodi spolu s lidmi, viz GET /people. */
groups: Array<{ id: string; name: string }>;
meId: string | null;
}