Log rekne co zpusobilo jakou zmenu, prevzeti ticketu a pozvanky

Nalezeno na bezicim serveru: TK-4946 mel 177 udalosti a 620 radku logu,
pritom se skoro nic nestalo. Zmereno proti fronte: ve stejnem okne vzniklo
presne tolik behu, kolik prislo udalosti (22 a 22), kazdy s jednim pokusem.
Fronta nenasobi nic, odesilatel poslal 177 POSTu. Nase vina byla, ze to
z historie neslo poznat.

- Data udalosti se ukladaji. Kdyz krok nema vlastni, ulozi se to, cim beh
  zacal - u webhooku prijate telo. Prazdna udalost je horsi nez zadna.
- Shodna udalost se pocita (repeats, lastAt), nezaklada dalsi radek. Ticket
  se pritom nemeni, takze duplikat nerozblika dashboard ani nespusti
  automatizaci na zmenu ticketu. Zahodit ji nejde, jinak by nikdo nezjistil,
  ze proti nam neco tluce.
- Zmeny se radi pod udalost, ktera je zpusobila, a u udalosti stoji jmeno
  automatizace. Log se cte jako "prislo tohle -> zmenilo to tohle".
- Poznamka o stavu jen kdyz se stav zmenil. "z in-progress na in-progress"
  u kazde zpravy byl zdroj tech 620 radku.
- runsToday konecne znamena dnes: behy po dnech, k tomu vcera a celkem.
  Dosud to byl citac od zalozeni automatizace, jen se jmenoval "dnes".

Vedle toho prace, o kterou slo predtim:
- Prevzeti ticketu ze skupiny (POST /tickets/:id/claim) a krok Predat skupine
  s prepinacem automatickeho prideleni nejvolnejsimu.
- Pozvanky do firmy: odkaz s nahodnym kodem, heslo si nastavi pozvany.
- Resitele, skupiny a pozvanky presunuty z Nastaveni do zalozky Lide, cleny
  skupiny se vybiraji klikanim.
- Ctyri AI znaky, ktere zbyvaly v kodu, pryc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-17 15:54:34 +02:00
co-authored by Claude Opus 5
parent fbe5b8ff6f
commit f1e8253169
30 changed files with 1839 additions and 107 deletions
+4
View File
@@ -9,6 +9,7 @@ import { Spinner } from '@/components/ui/Spinner';
import Home from '@/pages/Home';
import About from '@/pages/About';
import Contact from '@/pages/Contact';
import Invite from '@/pages/Invite';
import Login from '@/pages/Login';
import NotFound from '@/pages/NotFound';
import Services from '@/pages/Services';
@@ -48,6 +49,9 @@ export default function App() {
{/* Prihlaseni - vlastni layout bez hlavicky a paticky */}
<Route path="/prihlaseni" element={<Login />} />
{/* Pozvanka do firmy. Verejna: kdo sem prijde, jeste ucet mit nemusi. */}
<Route path="/pozvanka/:code" element={<Invite />} />
{/* Klientsky portal */}
<Route element={<RequireAuth />}>
<Route
@@ -0,0 +1,265 @@
import { Check, Copy, Link2, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
/**
* Pozvanky do firmy.
*
* Spravce vytvori odkaz, posle ho, jak chce, a clovek si za nim vyplni jmeno
* a heslo. Heslo se **nikdy neposila** - nastavuje si ho sam az na konci.
*
* Odkaz se ukazuje cely a s tlacitkem kopirovat: relativni cesta se do zpravy
* vlepit neda a rucni prepisovani kodu o 32 znacich je omyl cekajici na sve
* misto.
*/
interface InviteRow {
id: string;
code: string;
url: string;
email: string;
note: string;
roleIds: string[];
asPerson: boolean;
expiresAt: string;
usedAt: string | null;
createdByEmail: string;
createdAt: string;
problem: string | null;
}
interface InvitesResponse {
items: InviteRow[];
roles: Array<{ id: string; key: string; name: string }>;
}
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 InvitePanel() {
const invites = useApiQuery<InvitesResponse>('/api/dashboard/invites');
const [email, setEmail] = useState('');
const [note, setNote] = useState('');
const [roleIds, setRoleIds] = useState<string[]>([]);
const [asPerson, setAsPerson] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const roles = invites.data?.roles ?? [];
const items = invites.data?.items ?? [];
async function create() {
setBusy(true);
setError(null);
try {
await apiFetch('/api/dashboard/invites', {
method: 'POST',
body: { email, note, roleIds, asPerson },
});
setEmail('');
setNote('');
invites.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo vytvořit.');
} finally {
setBusy(false);
}
}
async function remove(id: string) {
try {
await apiFetch(`/api/dashboard/invites/${id}`, { method: 'DELETE' });
invites.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo zrušit.');
}
}
async function copy(url: string, id: string) {
try {
await navigator.clipboard.writeText(url);
setCopied(id);
window.setTimeout(() => setCopied(null), 1600);
} catch (err) {
console.warn('[pozvanky] schranka neni dostupna:', err);
}
}
return (
<div className="space-y-5">
<section className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Pozvat do firmy</h2>
<p className="mt-1 text-sm text-white/50">
Vznikne odkaz s jednorázovým kódem. Pošlete ho, jak chcete. Kdo ho otevře, vyplní
si jméno a heslo a je uvnitř. Platí týden.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm text-white/70">E-mail</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="nepovinné"
className={inputClass}
/>
<span className="mt-1 block text-xs text-white/35">
Když ho vyplníte, odkaz přijme jen tahle adresa. Prázdné = kdokoliv s odkazem.
</span>
</label>
<label className="block">
<span className="mb-1 block text-sm text-white/70">Poznámka</span>
<input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="komu jsem to poslal"
className={inputClass}
/>
</label>
</div>
<fieldset className="mt-4">
<legend className="mb-1 text-sm text-white/70">Role ve firmě</legend>
<div className="flex flex-wrap gap-2">
{roles.map((role) => {
const on = roleIds.includes(role.id);
return (
<button
key={role.id}
type="button"
onClick={() =>
setRoleIds((prev) =>
on ? prev.filter((id) => id !== role.id) : [...prev, role.id],
)
}
className={
on
? 'rounded-lg border border-brand-400/60 bg-brand-500/15 px-3 py-1.5 text-sm text-brand-100'
: 'rounded-lg border border-ink-600/70 px-3 py-1.5 text-sm text-white/60 hover:text-white'
}
>
{role.name}
</button>
);
})}
</div>
</fieldset>
<label className="mt-4 flex items-start gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={asPerson}
onChange={(event) => setAsPerson(event.target.checked)}
className="mt-0.5 size-4 accent-brand-400"
/>
<span>
Založit i jako řešitele
<span className="mt-0.5 block text-xs text-white/35">
Řešitel je ten, komu jde přehodit ticket. Účetní může mít přístup do portálu,
aniž by kdy ticket řešila.
</span>
</span>
</label>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<Button
onClick={() => void create()}
disabled={busy || roleIds.length === 0}
className="mt-4"
>
{busy ? 'Vytvářím...' : 'Vytvořit odkaz'}
</Button>
{roleIds.length === 0 && (
<span className="ml-3 text-xs text-white/35">Vyberte aspoň jednu roli.</span>
)}
</section>
<DataState
loading={invites.loading}
error={invites.error}
onRetry={invites.reload}
empty={items.length === 0}
emptyLabel="Zatím žádné pozvánky."
>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full min-w-150 border-collapse text-sm">
<thead>
<tr className="border-b border-ink-600/50 text-left text-xs tracking-wide text-white/35 uppercase">
<th className="px-4 py-3 font-medium">Pro koho</th>
<th className="px-4 py-3 font-medium">Odkaz</th>
<th className="px-4 py-3 font-medium">Stav</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{items.map((invite) => (
<tr key={invite.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2.5">
<span className="font-medium text-white">
{invite.email || 'kdokoliv s odkazem'}
</span>
{invite.note && (
<span className="mt-0.5 block text-xs text-white/40">{invite.note}</span>
)}
</td>
<td className="px-4 py-2.5">
<button
type="button"
onClick={() => void copy(invite.url, invite.id)}
className="flex max-w-90 items-center gap-2 text-left text-xs text-white/45 transition-colors hover:text-brand-200"
title={invite.url}
>
{copied === invite.id ? (
<Check className="size-3.5 shrink-0 text-ok-400" />
) : (
<Copy className="size-3.5 shrink-0" />
)}
<span className="truncate">{invite.url}</span>
</button>
</td>
<td className="px-4 py-2.5">
{invite.usedAt ? (
<Badge tone="ok">použitá {formatDateTime(invite.usedAt)}</Badge>
) : invite.problem ? (
<Badge tone="warn">{invite.problem}</Badge>
) : (
<span className="text-white/45">
platí do {formatDateTime(invite.expiresAt)}
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => void remove(invite.id)}
title="Zrušit pozvánku"
className="text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</DataState>
<p className="flex items-start gap-2 text-xs text-white/35">
<Link2 className="mt-0.5 size-3.5 shrink-0" />
Odkaz je jediná ochrana, proto má náhodný kód a jde kdykoliv zrušit. Po použití
přestane platit sám.
</p>
</div>
);
}
+15 -2
View File
@@ -18,7 +18,7 @@ export function TicketEvents({ events }: { events: TicketEvent[] }) {
if (events.length === 0) {
return (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
Žádné příchozí události. Ticket vznikl v portálu nebo ze simulace.
Žádné příchozí události. Ticket vznikl v portálu nebo ručně.
</p>
);
}
@@ -49,11 +49,24 @@ function EventRow({ event }: { event: TicketEvent }) {
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-white/80">{event.label}</span>
<span className="flex flex-wrap items-center gap-2 text-sm text-white/80">
{event.label}
{/*
Opakovani jako informace u radku, ne jako dalsi radek. Odesilatel
umi poslat totez osmdesatkrat a osmdesat radku znamena, ze
v historii nikdo nic nenajde.
*/}
{event.repeats > 1 && (
<span className="rounded-md bg-warn-400/15 px-1.5 py-0.5 text-xs font-semibold text-warn-300">
{event.repeats}x beze změny
</span>
)}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/35">
<code className="text-brand-300/80">{event.type}</code>
<span>{event.source}</span>
<span>{formatDateTime(event.at)}</span>
{event.repeats > 1 && <span>naposledy {formatDateTime(event.lastAt)}</span>}
</span>
</span>
+1 -1
View File
@@ -47,7 +47,7 @@ function ReferenceCard({ reference }: { reference: Reference }) {
</div>
<blockquote className="mt-5 flex-1 text-[0.975rem] leading-relaxed text-white/70">
„{reference.quote}“
"{reference.quote}"
</blockquote>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-ink-600/60 pt-4">
+1 -1
View File
@@ -62,7 +62,7 @@ export const products: Product[] = [
name: 'Dashboardy a reporting',
tagline: 'Jedna obrazovka místo pěti exportů',
description:
'Data z různých zdrojů na jednom místě — v reálném čase, s historií a s alerty, když se něco vymkne.',
'Data z různých zdrojů na jednom místě, v reálném čase, s historií a s alerty, když se něco vymkne.',
icon: BarChart3,
features: ['Metriky v reálném čase', 'Automatické reporty e-mailem', 'Přístupy podle rolí'],
metric: { value: '5 s', label: 'aktualizace dat' },
+179
View File
@@ -0,0 +1,179 @@
import { AlertCircle, Building2, CheckCircle2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Button } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
/**
* Prijeti pozvanky do firmy.
*
* Verejna stranka: kdo sem prijde, jeste neni prihlaseny. Kod v adrese je
* jedina ochrana, proto se tu neukazuje nic o firme krome jmena.
*
* Kdyz uz clovek ucet ma, zada k nemu svoje heslo a jen se pripoji k dalsi
* firme. Heslo se nikdy neposila mailem - nastavuje si ho sam tady.
*/
interface InviteInfo {
tenant: string;
email: string | null;
knownUser: boolean;
valid: boolean;
problem: string | null;
}
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none';
export default function Invite() {
const { code = '' } = useParams();
const navigate = useNavigate();
const { login } = useAuth();
usePageMeta({ title: 'Pozvánka - portál Automia' });
const [info, setInfo] = useState<InviteInfo | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
apiFetch<InviteInfo>(`/api/invites/${code}`, { auth: false })
.then((data) => {
setInfo(data);
if (data.email) setEmail(data.email);
})
.catch((err: unknown) => {
setLoadError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo načíst.');
});
}, [code]);
async function accept() {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/invites/${code}/accept`, {
method: 'POST',
auth: false,
body: { name, email, password },
});
setDone(true);
// Rovnou prihlasit: clovek uz heslo zadal, druhy formular by byl obtez.
await login(email, password);
navigate('/dashboard', { replace: true });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo přijmout.');
} finally {
setBusy(false);
}
}
return (
<main className="flex min-h-screen items-center py-16">
<Container>
<div className="glass mx-auto max-w-md rounded-card p-6 sm:p-8">
{loadError || (info && !info.valid) ? (
<>
<p className="flex items-start gap-2 text-danger-400">
<AlertCircle className="mt-0.5 size-5 shrink-0" />
<span>{info?.problem ?? loadError}</span>
</p>
<p className="mt-4 text-sm text-white/50">
Požádejte o novou pozvánku toho, kdo vám poslal odkaz.
</p>
<Link
to="/"
className="mt-4 inline-block text-sm font-medium text-brand-300 hover:text-brand-200"
>
Zpět na úvod
</Link>
</>
) : done ? (
<p className="flex items-center gap-2 text-ok-400">
<CheckCircle2 className="size-5" />
Hotovo, přesměrovávám do portálu.
</p>
) : (
<>
<p className="flex items-center gap-2 text-sm text-white/45">
<Building2 className="size-4 text-brand-300" />
Pozvánka do firmy
</p>
<h1 className="mt-1 text-2xl font-bold text-white">
{info?.tenant ?? 'Načítám...'}
</h1>
<p className="mt-2 text-sm text-white/55">
{info?.knownUser
? 'S touhle adresou už účet máte. Zadejte k němu svoje heslo a připojíte se k firmě.'
: 'Vyplňte jméno a heslo. Účet vznikne hned a rovnou vás přihlásíme.'}
</p>
<div className="mt-6 space-y-4">
<label className="block">
<span className="mb-1 block text-sm text-white/70">Jméno a příjmení</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
className={inputClass}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-white/70">E-mail</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/* Kdyz je pozvanka na konkretni adresu, nejde ji zmenit. */
readOnly={Boolean(info?.email)}
autoComplete="email"
className={inputClass}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-white/70">
{info?.knownUser ? 'Vaše heslo' : 'Nové heslo'}
</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={info?.knownUser ? 'current-password' : 'new-password'}
className={inputClass}
/>
{!info?.knownUser && (
<span className="mt-1 block text-xs text-white/35">Aspoň 8 znaků.</span>
)}
</label>
{error && (
<p className="flex items-start gap-2 text-sm text-danger-400">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
)}
<Button
onClick={() => void accept()}
disabled={busy || name.trim().length < 2 || password.length < 8}
className="w-full justify-center"
>
{busy ? 'Odesílám...' : 'Přijmout pozvánku'}
</Button>
</div>
</>
)}
</div>
</Container>
</main>
);
}
@@ -403,6 +403,8 @@ export default function AutomationDetail() {
<Row label="Vytvořeno" value={formatDateTime(automation.data.createdAt)} />
<Row label="Poslední úprava" value={formatDateTime(automation.data.updatedAt)} />
<Row label="Spuštění dnes" value={String(automation.data.runsToday)} />
<Row label="Spuštění včera" value={String(automation.data.runsYesterday)} />
<Row label="Spuštění celkem" value={String(automation.data.runsTotal)} />
<Row label="Úspěšnost" value={`${automation.data.successRate} %`} />
</dl>
</div>
+8 -2
View File
@@ -132,7 +132,11 @@ function AutomationCard({ automation }: { automation: Automation }) {
</div>
<dl className="mt-5 grid grid-cols-2 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-4">
<Metric label="Dnes" value={formatNumber(automation.runsToday)} />
<Metric
label="Dnes"
value={formatNumber(automation.runsToday)}
note={`včera ${formatNumber(automation.runsYesterday)}`}
/>
<Metric label="Úspěšnost" value={formatPercent(automation.successRate)} />
<Metric
label="Prům. běh"
@@ -144,11 +148,13 @@ function AutomationCard({ automation }: { automation: Automation }) {
);
}
function Metric({ label, value }: { label: string; value: string }) {
function Metric({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<div>
<dt className="text-xs text-white/35">{label}</dt>
<dd className="mt-0.5 text-sm font-semibold text-white tabular-nums">{value}</dd>
{/* Samo cislo za dnesek nic nerika, dokud neni s cim srovnat. */}
{note && <dd className="text-xs text-white/30 tabular-nums">{note}</dd>}
</div>
);
}
+144 -3
View File
@@ -1,13 +1,21 @@
import { Mail, Users } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { EntityAdmin } from '@/components/dashboard/EntityAdmin';
import { InvitePanel } from '@/components/dashboard/InvitePanel';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { PeopleResponse, Workload } from '@/types/dashboard';
import type { Access, PeopleResponse, Workload } from '@/types/dashboard';
interface NamedRecord {
id: string;
name?: string;
[key: string]: unknown;
}
/**
* Lide firmy: kdo resi tickety a jak na tom je.
@@ -19,6 +27,8 @@ export default function People() {
usePageMeta({ title: 'Lidé - portál Automia' });
const [view, setView] = useState<ListView>('table');
const [tab, setTab] = useState('prehled');
const access = useApiQuery<Access>('/api/dashboard/access');
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
@@ -29,6 +39,29 @@ export default function People() {
const byPerson = new Map(rows.map((row) => [row.person.id, row]));
const items = people.data?.items ?? [];
const can = useMemo(() => {
const owned = new Set(access.data?.permissions ?? []);
return (permission: string) => owned.has(permission);
}, [access.data]);
/** Cleny skupiny se vybiraji klikanim. Opisovat ID je zdroj preklepu. */
const personOptions = useMemo(
() => items.map((person) => ({ value: person.id, label: person.name })),
[items],
);
/*
* Vsechno o lidech je na jedne strance, ne v nastaveni. Pozvat kolegu
* a zalozit skupinu neni nastaveni portalu, je to bezna denni prace - a dokud
* to bylo schovane v nastaveni, nikdo to nenasel.
*/
const tabs = [
{ key: 'prehled', label: 'Přehled', show: true },
{ key: 'resitele', label: 'Řešitelé', show: can('people.manage') },
{ key: 'skupiny', label: 'Skupiny', show: can('group.manage') },
{ key: 'pozvanky', label: 'Pozvánky', show: can('user.manage') },
].filter((item) => item.show);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
@@ -38,9 +71,31 @@ export default function People() {
Kdo řeší tickety, co má u sebe a jak dlouho mu to leží.
</p>
</div>
<ViewSwitch view={view} onChange={setView} />
{tab === 'prehled' && <ViewSwitch view={view} onChange={setView} />}
</header>
{tabs.length > 1 && (
<div className="flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setTab(item.key)}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
tab === item.key
? 'bg-brand-500/15 text-brand-100'
: 'text-white/50 hover:text-white',
)}
>
{item.label}
</button>
))}
</div>
)}
{tab === 'prehled' && (
<>
{workload.data && (
<div className="flex flex-wrap gap-3 text-sm">
<Stat label="Nevyřízených celkem" value={workload.data.openTotal} />
@@ -174,6 +229,92 @@ export default function People() {
</div>
</section>
)}
</>
)}
{tab === 'resitele' && (
<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"
onChanged={people.reload}
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: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'skupiny' && (
<EntityAdmin
title="Skupiny řešitelů"
description="Ticket může ležet ve frontě skupiny. Kdo je ve skupině, si ho převezme sám."
endpoint="/api/dashboard/settings/groups"
onChanged={people.reload}
columns={[
{ label: 'Název', render: (group: NamedRecord) => String(group.name ?? '') },
{
label: 'Členů',
narrow: true,
render: (group: NamedRecord) =>
String((group.personIds as string[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'personIds',
label: 'Členové',
kind: 'multiselect',
options: personOptions,
hint: 'Automatické přidělování se nastavuje v kroku automatizace, ne tady.',
},
]}
/>
)}
{tab === 'pozvanky' && <InvitePanel />}
</div>
);
}
+5 -70
View File
@@ -17,7 +17,9 @@ import type { Access } from '@/types/dashboard';
* jeden zaznam v `tabs` nize.
*
* Akce a widgety tady **nejsou**: jsou to definice toho, co aplikace umi,
* stejna uroven jako automatizace, a maji vlastni zalozku.
* stejna uroven jako automatizace, a maji vlastni zalozku. Resitele, skupiny
* a pozvanky taky ne - patri k lidem, protoze pozvat kolegu je denni prace,
* ne nastaveni portalu.
*/
interface CatalogItem {
@@ -74,8 +76,6 @@ export default function Settings() {
const tabs = useMemo(() => {
const all: Array<{ key: string; label: string; show: boolean }> = [
{ key: 'ucet', label: 'Můj účet', show: true },
{ key: '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: 'users', label: 'Uživatelé', show: platformAdmin },
@@ -91,8 +91,8 @@ export default function Settings() {
<header>
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
<p className="mt-1 text-sm text-white/50">
Firmy, lidé, role a typy ticketů. Akce a widgety mají vlastní záložku,
jsou to definice, ne nastavení.
Firmy, role a typy ticketů. Řešitele, skupiny a pozvánky najdete
v záložce Lidé, akce a widgety mají vlastní záložku.
</p>
</header>
@@ -116,71 +116,6 @@ export default function Settings() {
{tab === 'ucet' && <MyAccount />}
{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: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{ 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"
+16
View File
@@ -278,6 +278,22 @@ export default function TicketDetail() {
{ticket.data.assignee ? ticket.data.assignee.name : 'Ve frontě, bez řešitele'}
</p>
{/*
Prevzeti: kdyz ticket lezi ve fronte skupiny, vezme si ho
clovek sam. Je to jina vec nez prehazovani prace shora, proto
tlacitko a ne polozka v selectu.
*/}
{people.data?.meId && ticket.data.assignee?.id !== people.data.meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {}, 'Převzetí se nepodařilo.')}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
+9
View File
@@ -79,6 +79,10 @@ export interface TicketEvent {
label: string;
payload: Record<string, unknown>;
at: string;
/** Kolikrat prislo totez. Vic nez 1 znamena, ze odesilatel posila opakovane. */
repeats: number;
/** Kdy naposledy. Same jako `at`, dokud se neopakuje. */
lastAt: string;
}
export interface Ticket {
@@ -208,7 +212,12 @@ export interface Automation {
name: string;
kind: AutomationKind;
enabled: boolean;
/** Behy za dnesni den. Po pulnoci zacina od nuly. */
runsToday: number;
/** Behy za vcerejsek, at je s cim srovnat. */
runsYesterday: number;
/** Behy od zalozeni automatizace. */
runsTotal: number;
successRate: number;
avgDurationMs: number;
lastRunAt: string;