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:
co-authored by
Claude Opus 5
parent
e7cf499a0b
commit
afbe948da3
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user