Realne sluzby, OpenAI, odesilani e-mailu a helpdesk

Katalog srovnany s tim, co opravdu bezi na services.csbot.cz/apps:
trinact sluzeb dostalo pristupove udaje a levne cteci overeni, opravena
appId, ktera nikam nevedla (ppl, microsoft365, transcription), a GA4,
Search Console, Google Ads i Sklik ted stoji na aplikaci analytics,
kazda s vlastnimi udaji. Nove sluzby SAP Business One, Google Workspace
a Meta Ads. K tomu 23 skriptu, ktere s nimi opravdu neco delaji.

OpenAI jako prvni sluzba, ktera nebezi u nas: Service.baseUrl s absolutni
adresou, prepis pres <SLUZBA>_BASE_URL nebo adresu u konektoru, predpona
hlavicky u pole udaju (uzivatel vlepi holy klic, Bearer dopise runtime).
Dotaz na model, nahrani souboru, otazka nad souborem, prepis zvuku.
Skript umi odeslat soubor pres ctx.http.postForm (multipart, obsah Base64).

Sluzba E-mail pres SMTP. Neni to skript, ale vnitrni krok - SMTP neni HTTP.
Konektor nese schranku firmy, krok ma HTML telo, ve kterem se dosazene
hodnoty escapuji (znacky autora sablony jsou zamer, ostre zavorky od
zakaznika ne). Overeni konektoru se prihlasi na server a nic neodesle.

Helpdesk: Ticket.helpdeskSourceId drzi firmu, ktera pozadavek poslala,
vlastnikem zustava ta, ktera ho resi - jinak by ho resitel nemel ve sve
fronte. Komu pozadavek pripadne, urcuje Tenant.helpdeskProviderId.
Zadavatel vidi jen svoje pozadavky a smi k nim pripsat komentar.

Opravy v portalu:
- hlasky o ulozisti a odchozi IP vidi jen spravce platformy
- typ ticketu se v automatizaci vybira ze seznamu firmy, nebo dosadi z dat
- stav ticketu je otevreny naseptavac, ne ciselnik
- ticket jde zalozit rucne, zakaznik u nej neni povinny
- kanal se prejmenoval a parametry u webhooku jsou oznacene jako nepovinne
- srovnane markdown tabulky v cele dokumentaci

Co z teto davky jeste neni: prepinac firmy je porad jen stav uvnitr stranky
Prehled, takze se prepnuti neprojevi v Lidech ani jinde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-26 07:40:16 +02:00
co-authored by Claude Opus 5
parent b25a149574
commit a771834e57
78 changed files with 7021 additions and 932 deletions
+2
View File
@@ -21,6 +21,7 @@ const DashboardServices = lazy(() => import('@/pages/dashboard/Services'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const Helpdesk = lazy(() => import('@/pages/dashboard/Helpdesk'));
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
@@ -75,6 +76,7 @@ export default function App() {
<Route path="konektory" element={<Connectors />} />
<Route path="skripty" element={<Scripts />} />
<Route path="tickety" element={<Tickets />} />
<Route path="helpdesk" element={<Helpdesk />} />
<Route path="tickety/:id" element={<TicketDetail />} />
<Route path="incidenty" element={<Incidents />} />
<Route path="nastaveni" element={<Settings />} />
@@ -5,6 +5,7 @@ import {
LayoutDashboard,
LayoutGrid,
LifeBuoy,
MessageSquarePlus,
LogOut,
Menu,
MousePointerClick,
@@ -41,6 +42,7 @@ const moduleRoutes: Record<string, { to: string; icon: typeof LayoutDashboard; e
connectors: { to: '/dashboard/konektory', icon: Plug, end: false },
scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false },
tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false },
helpdesk: { to: '/dashboard/helpdesk', icon: MessageSquarePlus, end: false },
people: { to: '/dashboard/lide', icon: Users, end: false },
actions: { to: '/dashboard/akce', icon: MousePointerClick, end: false },
widgets: { to: '/dashboard/widgety', icon: LayoutGrid, end: false },
@@ -0,0 +1,301 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { TicketPriority } from '@/types/dashboard';
/**
* Rucni zalozeni ticketu.
*
* Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze
* pozadavek casto prijde telefonem a nekdo ho musi zapsat - bez toho konci
* na papirku a v systemu neni.
*
* **Zakaznik je nepovinny a je schovany.** Rucne zalozeny ticket je casto ukol,
* ne pozadavek od nekoho zvenku, a povinna firma s kontaktem by znamenala, ze
* si je clovek vymysli.
*/
interface TicketTypeOption {
id: string;
name: string;
}
interface PeopleResponse {
items: Array<{ id: string; name: string }>;
groups: Array<{ id: string; name: string }>;
}
const priorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Normální' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="text-xs text-white/55">{label}</span>
{children}
{hint && <span className="mt-0.5 block text-xs text-white/35">{hint}</span>}
</label>
);
}
export function NewTicketDialog({
open,
tenantId,
onClose,
onCreated,
}: {
open: boolean;
/** Firma, do ktere ticket patri. Posila se na server, ne se vybira ve formulari. */
tenantId: string | null;
onClose: () => void;
onCreated: () => void;
}) {
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [priority, setPriority] = useState<TicketPriority>('normal');
const [typeId, setTypeId] = useState('');
const [assigneeId, setAssigneeId] = useState('');
const [assigneeGroupId, setAssigneeGroupId] = useState('');
const [customerOpen, setCustomerOpen] = useState(false);
const [company, setCompany] = useState('');
const [contact, setContact] = useState('');
const [reply, setReply] = useState('');
const [types, setTypes] = useState<TicketTypeOption[]>([]);
const [people, setPeople] = useState<PeopleResponse | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setError(null);
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
// Typy i řešitelé jsou vlastnost firmy, proto se načítají s jejím ID.
apiFetch<{ items: TicketTypeOption[] }>(`/api/dashboard/settings/ticket-types${query}`)
.then((response) => setTypes(response.items))
.catch(() => setTypes([]));
apiFetch<PeopleResponse>(`/api/dashboard/people${query}`)
.then(setPeople)
.catch(() => setPeople(null));
}, [open, tenantId]);
function reset() {
setSubject('');
setBody('');
setPriority('normal');
setTypeId('');
setAssigneeId('');
setAssigneeGroupId('');
setCustomerOpen(false);
setCompany('');
setContact('');
setReply('');
setError(null);
}
async function submit(event: React.FormEvent) {
event.preventDefault();
if (subject.trim() === '') {
setError('Vyplňte předmět, jinak ticket v seznamu nikdo nepozná.');
return;
}
setSaving(true);
setError(null);
try {
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
await apiFetch(`/api/dashboard/tickets${query}`, {
method: 'POST',
body: JSON.stringify({
subject: subject.trim(),
body,
priority,
typeId: typeId === '' ? null : typeId,
assigneeId: assigneeId === '' ? null : assigneeId,
assigneeGroupId: assigneeGroupId === '' ? null : assigneeGroupId,
// Prázdné údaje se neposílají: zákazník je nepovinný.
...(company || contact || reply ? { customer: { company, contact, reply } } : {}),
}),
});
reset();
onCreated();
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ticket se nepodařilo založit.');
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Nový ticket"
description="Požadavek, který přišel mimo automatizaci - telefonem, osobně, odkudkoliv."
className="max-w-2xl"
>
<form onSubmit={submit} className="space-y-4 p-5">
<Field label="Předmět">
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Co se má vyřešit"
className={cn(inputClass, 'mt-1')}
autoFocus
/>
</Field>
<Field label="Popis" hint="Celý text požadavku. Nepovinné.">
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={5}
className={cn(inputClass, 'mt-1 resize-y')}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Priorita">
<select
value={priority}
onChange={(event) => setPriority(event.target.value as TicketPriority)}
className={cn(inputClass, 'mt-1')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</Field>
<Field label="Typ" hint="Za typem stojí vlastní pole a akce na ticketu.">
<select
value={typeId}
onChange={(event) => setTypeId(event.target.value)}
className={cn(inputClass, 'mt-1')}
>
<option value="" className="bg-ink-850">
Bez typu
</option>
{types.map((type) => (
<option key={type.id} value={type.id} className="bg-ink-850">
{type.name}
</option>
))}
</select>
</Field>
<Field label="Řešitel">
<select
value={assigneeId}
onChange={(event) => setAssigneeId(event.target.value)}
className={cn(inputClass, 'mt-1')}
>
<option value="" className="bg-ink-850">
Nechat ve frontě
</option>
{(people?.items ?? []).map((person) => (
<option key={person.id} value={person.id} className="bg-ink-850">
{person.name}
</option>
))}
</select>
</Field>
<Field label="Skupina">
<select
value={assigneeGroupId}
onChange={(event) => setAssigneeGroupId(event.target.value)}
className={cn(inputClass, 'mt-1')}
>
<option value="" className="bg-ink-850">
Bez skupiny
</option>
{(people?.groups ?? []).map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
</Field>
</div>
{/*
Zákazník je schovaný, protože u ručně založeného ticketu obvykle
žádný není. Rozbalí ho ten, kdo ho opravdu má.
*/}
<div className="border-t border-ink-600/50 pt-4">
{customerOpen ? (
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Firma">
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
className={cn(inputClass, 'mt-1')}
/>
</Field>
<Field label="Kontaktní osoba">
<input
value={contact}
onChange={(event) => setContact(event.target.value)}
className={cn(inputClass, 'mt-1')}
/>
</Field>
<Field label="Kam odpovídat">
<input
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder="E-mail nebo telefon"
className={cn(inputClass, 'mt-1')}
/>
</Field>
</div>
) : (
<button
type="button"
onClick={() => setCustomerOpen(true)}
className="text-sm text-white/50 transition-colors hover:text-white"
>
Přidat zákazníka (nepovinné)
</button>
)}
</div>
{error && (
<p className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
{error}
</p>
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Zakládám...' : 'Založit ticket'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
</div>
</form>
</Modal>
);
}
@@ -165,7 +165,56 @@ export function StepInputs({
</option>
))}
</select>
) : field.kind === 'longtext' ? (
) : field.kind === 'lookup' ? (
/*
Ciselnik a volny text zaroven.
Duvod, proc to neni obycejny `choice`: hodnotu casto zna az beh,
takze se dosazuje sablonou (`{{data.typ}}`). A duvod, proc to
neni obycejny `text`: nikdo nema opisovat ID typu z jine
stranky. Vyber tedy jen **vyplni** textove pole, hodnota
zustava jedna.
*/
<div className="mt-1 flex gap-2">
<input
id={`input-${field.id}`}
ref={(element) => {
if (element) elements.current.set(field.id, element);
else elements.current.delete(field.id);
}}
value={value}
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder="Vyberte vpravo, nebo dosaďte {{parametr}}"
className={cn(
inputClass,
'flex-1',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
<select
aria-label={`${field.label}: vybrat ze seznamu`}
value={
field.options?.some((option) => option.value === value) ? value : ''
}
onChange={(event) => set(field.id, event.target.value)}
className={cn(inputClass, 'w-40 shrink-0 border-ink-600/70')}
>
<option value="" className="bg-ink-850">
ze seznamu
</option>
{field.options
?.filter((option) => option.value !== '')
.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
</div>
) : field.kind === 'longtext' || field.kind === 'html' ? (
<textarea
id={`input-${field.id}`}
ref={(element) => {
@@ -173,13 +222,16 @@ export function StepInputs({
else elements.current.delete(field.id);
}}
value={value}
rows={3}
// HTML se pise po odstavcich, ne po vete. Tri radky by
// znamenaly psat telo e-mailu do skulinky.
rows={field.kind === 'html' ? 10 : 3}
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
className={cn(
inputClass,
'mt-1 resize-y',
field.kind === 'html' && 'font-mono text-xs',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
@@ -123,10 +123,12 @@ function CustomFields({
<div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
<h3 className="text-sm font-semibold text-white">
Vstupní parametry <span className="font-normal text-white/40">nepovinné</span>
</h3>
<p className="mt-0.5 text-xs text-white/45">
{editable
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
? 'Vyplňovat je ručně nemusíte. Jednodušší je vlepit níže ukázku toho, co vám přijde - parametry se z ní vyčtou samy. Webhook přijme cokoliv, tohle je jen seznam hodnot, na které pak jdou stavět podmínky.'
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
</p>
</div>
@@ -142,8 +144,9 @@ function CustomFields({
{fields.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
Zatím žádné parametry. Bez nich nelze přidat podmínku - nebylo by podle čeho
se rozhodovat.
Zatím žádné parametry, a to je v pořádku. Spouštěč funguje i bez nich.
Přidají se samy, jakmile níže vlepíte ukázku toho, co vám přijde -
ručně je psát nemusíte.
</p>
) : (
<ul className="mt-3 space-y-2">
+6
View File
@@ -1,8 +1,11 @@
import {
BarChart3,
Bot,
Boxes,
Building2,
Chrome,
Clock,
Database,
Facebook,
FileAudio,
FileInput,
@@ -39,9 +42,12 @@ import {
*/
const icons: Record<string, LucideIcon> = {
BarChart3,
Bot,
Boxes,
Building2,
Chrome,
Clock,
Database,
Facebook,
FileAudio,
FileInput,
+41 -20
View File
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
import { Badge } from '@/components/ui/Badge';
@@ -50,6 +51,7 @@ import type {
export default function Connectors() {
usePageMeta({ title: 'Konektory - portál Automia' });
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const preselected = params.get('service');
@@ -119,6 +121,8 @@ export default function Connectors() {
if (preselected) setParams({});
}
const platformAdmin = user?.platformAdmin === true;
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
@@ -138,12 +142,18 @@ export default function Connectors() {
</header>
{/*
Kam se uklada. Bez teto hlasky se clovek divi, kam se podely jeho
konektory, a hleda chybu v aplikaci - presne to se stalo.
*/}
{storage && storage.mode !== 'postgres' && <StorageNotice storage={storage} />}
Provozni hlasky **jen pro spravce platformy**.
{egress && <EgressNotice egress={egress} />}
Kam se uklada a z jake IP adresy volame ven jsou veci, ktere resime my,
ne zakaznik. Jemu nerikaji nic pouziteleho a "data se ukladaji do souboru
na serveru" na nej pusobi jako priznani, ze mu tu praci muzeme ztratit.
Nam to pouziteleho rika porad, takze se to nemaze, jen schovava.
*/}
{platformAdmin && storage && storage.mode !== 'postgres' && (
<StorageNotice storage={storage} />
)}
{platformAdmin && egress && <EgressNotice egress={egress} />}
<DataState loading={loading} error={error} onRetry={load}>
{(connectors?.length ?? 0) === 0 ? (
@@ -307,7 +317,9 @@ function ConnectorCard({
* a konektor ji smi prepsat, takze bez ni je "vratilo 403" jen pulka
* informace: nerika, jestli se to vubec trefilo na spravny stroj.
*/
const targetUrl = connector.baseUrl ?? service?.baseUrl ?? null;
// Prazdny retezec znamena, ze sluzba pevnou adresu nema - u SMTP ji nese
// az konektor ve svych udajich. Radek "miri na" by pak byl prazdny.
const targetUrl = connector.baseUrl ?? (service?.baseUrl || null);
const Icon = serviceIcon(service?.icon ?? 'Plug');
const [busy, setBusy] = useState(false);
const [test, setTest] = useState<ConnectorTestResult | null>(null);
@@ -466,6 +478,9 @@ function ConnectorLogs({
open: boolean;
onClose: () => void;
}) {
// Odchozi IP adresa je nase provozni vec, ne zakaznikova. V logu zustava,
// ale vidi ji jen spravce platformy - stejne jako hlasky nad seznamem.
const { user } = useAuth();
const [items, setItems] = useState<ConnectorCheck[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -508,7 +523,7 @@ function ConnectorLogs({
<span className="text-white/70">{formatDateTime(check.at)}</span>
{check.ok ? <Badge tone="ok">prošlo</Badge> : <Badge tone="danger">selhalo</Badge>}
<span>Ověřeno: {check.checked}</span>
{check.egressIp && (
{user?.platformAdmin && check.egressIp && (
<span>
voláno z IP <span className="font-mono text-white/60">{check.egressIp}</span>
</span>
@@ -757,19 +772,25 @@ function ConnectorEditor({
</p>
)}
<Field
label="Vlastní adresa služby"
hint="Nechte prázdné, pokud nemáte vlastní instanci."
>
<input
name="connector-endpoint"
autoComplete="off"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="Výchozí podle služby"
className={inputClass}
/>
</Field>
{/*
U SMTP nemá vlastní adresa co dělat: server, port i šifrování jsou
mezi údaji výše. Prázdné pole navíc by svádělo sem psát adresu znovu.
*/}
{service?.transport !== 'smtp' && (
<Field
label="Vlastní adresa služby"
hint="Nechte prázdné, pokud nemáte vlastní instanci."
>
<input
name="connector-endpoint"
autoComplete="off"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="Výchozí podle služby"
className={inputClass}
/>
</Field>
)}
{error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
+378
View File
@@ -0,0 +1,378 @@
import { MessageSquarePlus, Plus, Send } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { 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 { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Ticket, TicketDetail, TicketPriority } from '@/types/dashboard';
/**
* Helpdesk: pohled **zadavatele**.
*
* Firma tu vidi pozadavky, ktere sama poslala svemu dodavateli, a smi k nim
* pripsat komentar. Stav, resitele ani prioritu neurcuje - to je na tom, kdo
* pozadavek resi, a ten ho ma v zalozce Tickety jako kazdy jiny.
*
* Zamerne to **neni druhy seznam ticketu**: chybi tu filtry, prirazovani i
* fronta. Zadavatele nezajima, kdo to ma u sebe, ale jestli se to hnulo.
*/
interface HelpdeskResponse {
items: Ticket[];
tenantId: string;
provider: { id: string; name: string } | null;
canCreate: boolean;
}
const priorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Normální' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export default function Helpdesk() {
usePageMeta({ title: 'Helpdesk - portál Automia' });
const helpdesk = useApiQuery<HelpdeskResponse>('/api/dashboard/helpdesk', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
});
const [creating, setCreating] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const data = helpdesk.data;
const provider = data?.provider ?? null;
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Helpdesk</h1>
<p className="mt-1 max-w-2xl text-sm text-white/50">
{provider
? `Požadavky, které jste poslali na ${provider.name}. Uvidíte tu i to, jak se s nimi naložilo.`
: 'Požadavky, které jste poslali svému dodavateli.'}
</p>
</div>
{data?.canCreate && provider && (
<Button size="sm" onClick={() => setCreating(true)}>
<Plus className="size-4" />
Nový požadavek
</Button>
)}
</header>
{/*
Bez dodavatele nemá požadavek kam odejít. Říká se to rovnou a s tím,
co s tím - prázdný seznam by vypadal jako by se nic nestalo.
*/}
{data && !provider && (
<div className="rounded-card border border-warn-400/40 bg-warn-500/8 p-4 text-sm text-warn-400">
Vaše firma zatím nemá nastaveného dodavatele helpdesku, takže není komu
požadavek poslat. Ozvěte se nám a doplníme to.
</div>
)}
<DataState loading={helpdesk.loading} error={helpdesk.error} onRetry={helpdesk.reload}>
{(data?.items.length ?? 0) === 0 ? (
<div className="glass rounded-card p-10 text-center">
<MessageSquarePlus className="mx-auto size-8 text-white/25" />
<p className="mt-3 text-sm text-white/45">
Zatím jste neposlali žádný požadavek.
</p>
</div>
) : (
<ul className="space-y-3">
{(data?.items ?? []).map((ticket) => (
<li key={ticket.id}>
<button
type="button"
onClick={() => setOpenId(ticket.id)}
className="glass w-full rounded-card p-4 text-left transition-colors hover:border-brand-400/40 sm:p-5"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="font-mono text-xs text-white/40">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
{ticket.priority !== 'normal' && (
<Badge tone={ticket.priority === 'low' ? 'neutral' : 'warn'}>
{priorities.find((item) => item.value === ticket.priority)?.label}
</Badge>
)}
<span className="ml-auto text-xs text-white/35">
{formatRelative(ticket.updatedAt)}
</span>
</div>
<p className="mt-1.5 font-medium text-white">{ticket.subject}</p>
{ticket.body && (
<p className="mt-1 line-clamp-2 text-sm text-white/45">{ticket.body}</p>
)}
</button>
</li>
))}
</ul>
)}
</DataState>
<NewRequestDialog
open={creating}
providerName={provider?.name ?? null}
onClose={() => setCreating(false)}
onCreated={() => helpdesk.reload()}
/>
<RequestDetail id={openId} onClose={() => setOpenId(null)} onChanged={() => helpdesk.reload()} />
</div>
);
}
function NewRequestDialog({
open,
providerName,
onClose,
onCreated,
}: {
open: boolean;
providerName: string | null;
onClose: () => void;
onCreated: () => void;
}) {
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [priority, setPriority] = useState<TicketPriority>('normal');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent) {
event.preventDefault();
if (subject.trim() === '') {
setError('Napište, čeho se požadavek týká.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch('/api/dashboard/helpdesk', {
method: 'POST',
body: JSON.stringify({ subject: subject.trim(), body, priority }),
});
setSubject('');
setBody('');
setPriority('normal');
onCreated();
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Požadavek se nepodařilo poslat.');
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Nový požadavek"
description={
providerName
? `Odejde na ${providerName}. Uvidíte tu, jak se s ním naloží.`
: 'Popište, s čím potřebujete pomoct.'
}
className="max-w-2xl"
>
<form onSubmit={submit} className="space-y-4 p-5">
<label className="block">
<span className="text-xs text-white/55">Čeho se to týká</span>
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Krátce, jednou větou"
className={cn(inputClass, 'mt-1')}
autoFocus
/>
</label>
<label className="block">
<span className="text-xs text-white/55">Popis</span>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={7}
placeholder="Co se stalo, co jste zkusili, kde se to projevuje."
className={cn(inputClass, 'mt-1 resize-y')}
/>
</label>
<label className="block sm:w-56">
<span className="text-xs text-white/55">Naléhavost</span>
<select
value={priority}
onChange={(event) => setPriority(event.target.value as TicketPriority)}
className={cn(inputClass, 'mt-1')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</label>
{error && (
<p className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
{error}
</p>
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Odesílám...' : 'Odeslat požadavek'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
</div>
</form>
</Modal>
);
}
/**
* Detail pozadavku ocima zadavatele.
*
* Ukazuje se **prubeh**, ne fronta: co se s pozadavkem delo a co k nemu kdo
* napsal. Zadne prirazovani ani zmena stavu - to zadavateli nepatri.
*/
function RequestDetail({
id,
onClose,
onChanged,
}: {
id: string | null;
onClose: () => void;
onChanged: () => void;
}) {
const [ticket, setTicket] = useState<TicketDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (!id) return;
setTicket(null);
setError(null);
setText('');
apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`)
.then(setTicket)
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : 'Požadavek se nepodařilo načíst.'),
);
}, [id]);
async function comment(event: React.FormEvent) {
event.preventDefault();
if (!id || text.trim() === '') return;
setSending(true);
setError(null);
try {
await apiFetch(`/api/dashboard/helpdesk/${id}/comment`, {
method: 'POST',
body: JSON.stringify({ text: text.trim() }),
});
setText('');
const fresh = await apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`);
setTicket(fresh);
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Komentář se nepodařilo uložit.');
} finally {
setSending(false);
}
}
return (
<Modal
open={id !== null}
onClose={onClose}
title={ticket ? ticket.subject : 'Požadavek'}
description={ticket ? `${ticket.id}, založeno ${formatDateTime(ticket.createdAt)}` : undefined}
className="max-w-3xl"
>
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-5">
{error && <p className="text-sm text-danger-400">{error}</p>}
{!error && !ticket && <p className="text-sm text-white/40">Načítám...</p>}
{ticket && (
<>
<div className="flex flex-wrap items-center gap-2">
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
<span className="text-xs text-white/35">
naposledy {formatRelative(ticket.updatedAt)}
</span>
</div>
{ticket.body && (
<p className="rounded-card border border-ink-600/50 bg-ink-850/50 p-4 text-sm whitespace-pre-wrap text-white/70">
{ticket.body}
</p>
)}
<div>
<h3 className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Průběh
</h3>
{ticket.trace.length === 0 ? (
<p className="mt-2 text-sm text-white/40">Zatím se nic nestalo.</p>
) : (
<ul className="mt-2 space-y-2">
{ticket.trace.map((entry) => (
<li
key={entry.id}
className="rounded-lg border border-ink-600/50 p-3 text-sm text-white/70"
>
<div className="flex flex-wrap items-center gap-x-3 text-xs text-white/35">
<span>{formatDateTime(entry.at)}</span>
</div>
<p className="mt-1">{entry.label}</p>
{entry.response && (
<p className="mt-1 text-xs text-white/45">{entry.response}</p>
)}
</li>
))}
</ul>
)}
</div>
<form onSubmit={comment} className="border-t border-ink-600/50 pt-4">
<label className="block">
<span className="text-xs text-white/55">Doplnit k požadavku</span>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={3}
placeholder="Co jste zapomněli napsat, nebo co se mezitím změnilo."
className={cn(inputClass, 'mt-1 resize-y')}
/>
</label>
<Button type="submit" size="sm" className="mt-3" disabled={sending || text.trim() === ''}>
<Send className="size-4" />
{sending ? 'Odesílám...' : 'Odeslat'}
</Button>
</form>
</>
)}
</div>
</Modal>
);
}
+37
View File
@@ -50,6 +50,15 @@ export default function Settings() {
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const catalog = useApiQuery<SettingsCatalog>('/api/dashboard/settings/catalog');
/*
* Seznam firem zvlast, i kdyz si ho `EntityAdmin` nacita sam. Potrebuje se
* do nabidky "helpdesk resi firma", tedy do konfigurace tehoz seznamu -
* dostat se k tomu, co si komponenta nacetla dovnitr, by znamenalo
* protahnout to skrz ni jen kvuli jednomu poli.
*/
const tenants = useApiQuery<{ items: Array<{ id: string; name: string }> }>(
'/api/dashboard/settings/tenants',
);
const [tab, setTab] = useState('ucet');
const can = useMemo(() => {
@@ -217,6 +226,15 @@ export default function Settings() {
{ 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: 'Helpdesk řeší',
render: (tenant: NamedRecord) => {
const provider = tenants.data?.items.find(
(item) => item.id === tenant.helpdeskProviderId,
);
return provider ? provider.name : <span className="text-white/30">nikdo</span>;
},
},
{
label: 'Stav',
narrow: true,
@@ -228,6 +246,25 @@ export default function Settings() {
{ 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 },
{
/*
Komu firma posila pozadavky z helpdesku. Urcuje to spravce
platformy, ne firma sama: kdo koho obsluhuje je obchodni vztah.
Prazdna hodnota znamena, ze helpdesk nema komu poslat, a rekne
se to na strance Helpdesk nahlas.
*/
name: 'helpdeskProviderId',
label: 'Helpdesk řeší firma',
kind: 'select',
hint: 'Komu půjdou požadavky z helpdesku této firmy. Bez vyplnění je poslat nelze.',
options: [
{ value: '', label: 'Nikdo' },
...(tenants.data?.items ?? []).map((tenant) => ({
value: tenant.id,
label: tenant.name,
})),
],
},
]}
/>
)}
+48 -16
View File
@@ -66,6 +66,8 @@ export default function TicketDetail() {
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const [types, setTypes] = useState<Array<{ id: string; statuses: string[] }>>([]);
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; statuses: string[] }> }>(
@@ -73,6 +75,9 @@ export default function TicketDetail() {
)
.then((data) => setTypes(data.items))
.catch(() => setTypes([]));
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => setUsedStatuses(data.items))
.catch(() => setUsedStatuses([]));
}, []);
const statusChoices = useMemo(() => {
@@ -80,8 +85,17 @@ export default function TicketDetail() {
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
const current = ticket.data?.status;
return [...new Set([...base, ...(current ? [current] : [])])];
}, [types, ticket.data?.typeId, ticket.data?.status]);
return [...new Set([...base, ...usedStatuses, ...(current ? [current] : [])])];
}, [types, usedStatuses, ticket.data?.typeId, ticket.data?.status]);
/*
* Rozepsany stav.
*
* Drzi se zvlast od ticketu, protoze zapis nesmi odejit na server po kazdem
* pismenu - "Ce" na ceste k "Ceka na zakaznika" je platny stav a ulozil by se.
* Posila se az pri opusteni pole nebo pri Enteru.
*/
const [statusDraft, setStatusDraft] = useState<string | null>(null);
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} - portál Automia` });
@@ -363,25 +377,43 @@ export default function TicketDetail() {
>
Stav
</label>
<select
{/*
Otevreny naseptavac, ne ciselnik. Stav je volny retezec:
ticket muze prijit z cizi aplikace s jejim vlastnim stavem
a firma si smi zavest vlastni, aniz by ho nekdo predem
zapisoval do nastaveni. Nabidka je pohodli, ne omezeni.
*/}
<input
id="ticket-status"
value={ticket.data.status}
list="ticket-status-options"
value={statusDraft ?? ticket.data.status}
disabled={busy}
onChange={(event) =>
void mutate(
'/status',
{ status: event.target.value },
'Změna stavu se nepodařila.',
)
}
onChange={(event) => setStatusDraft(event.target.value)}
onBlur={(event) => {
const next = event.target.value.trim();
setStatusDraft(null);
if (next === '' || next === ticket.data?.status) return;
void mutate('/status', { status: next }, 'Změna stavu se nepodařila.');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
// Escape zahodi rozepsane a vrati puvodni hodnotu.
if (event.key === 'Escape') {
setStatusDraft(null);
event.currentTarget.blur();
}
}}
placeholder="Napište stav, nebo vyberte z nabídky"
className={cn(selectClass, 'mt-2 w-full')}
>
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} className="bg-ink-850">
{option}
</option>
<option key={option} value={option} />
))}
</select>
</datalist>
<p className="mt-1 text-xs text-white/35">
Stav je volný text. Nabídka je jen z toho, co už používáte.
</p>
</div>
<div className="glass rounded-card p-5">
+25 -2
View File
@@ -1,4 +1,4 @@
import { Search } from 'lucide-react';
import { Plus, Search } from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
@@ -8,10 +8,12 @@ import {
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { NewTicketDialog } from '@/components/dashboard/NewTicketDialog';
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
import { TicketTable } from '@/components/dashboard/TicketTable';
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
@@ -74,6 +76,7 @@ export default function Tickets() {
const [query, setQuery] = useState('');
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
const [view, setView] = useState<'table' | 'grid'>('table');
const [creating, setCreating] = useState(false);
/** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */
const [mine, setMine] = useState(false);
@@ -123,6 +126,7 @@ export default function Tickets() {
const meId = data?.meId ?? null;
const filtered = assignee !== null || status !== null || channel !== null;
const canCreate = access.data?.permissions.includes('ticket.create') === true;
const allowedScopes = access.data?.scopes ?? [];
const tenants = access.data?.tenants ?? [];
// Dokud server neodpovi, drzime se toho, co poslal u seznamu.
@@ -138,9 +142,28 @@ export default function Tickets() {
V detailu je vidět, co která služba vrátila.
</p>
</div>
<ViewSwitch view={view} onChange={setView} />
<div className="flex items-center gap-3">
{/*
Ticket nevznika jen z automatizace. Pozadavek casto prijde telefonem
a nekdo ho musi zapsat - bez tohohle tlacitka konci na papirku.
*/}
{canCreate && (
<Button size="sm" onClick={() => setCreating(true)}>
<Plus className="size-4" />
Nový ticket
</Button>
)}
<ViewSwitch view={view} onChange={setView} />
</div>
</header>
<NewTicketDialog
open={creating}
tenantId={tenantId ?? data?.tenantId ?? null}
onClose={() => setCreating(false)}
onCreated={() => tickets.reload()}
/>
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<div className="space-y-4">
<div className="glass rounded-card p-4 sm:p-5">
+5 -1
View File
@@ -93,6 +93,8 @@ export interface Ticket {
* ID u odesilatele, napr. cislo objednavky. **Unikatni v ramci firmy.**
* Dalsi zprava se stejnym ID se navesi na tenhle ticket.
*/
/** Firma, ktera pozadavek poslala pres helpdesk. null u beznych ticketu. */
helpdeskSourceId: string | null;
externalId: string | null;
/** Odkud externi ID pochazi. Jen informace, do klice nevstupuje. */
externalSource: string | null;
@@ -300,7 +302,7 @@ export interface OperationField {
* `json` a `mapping` nesou strukturu zapsanou jako JSON v retezci.
* `object` je odkaz na parametr typu objekt nebo seznam.
*/
kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object';
kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
@@ -317,6 +319,8 @@ export interface Service {
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
general: boolean;
appId: string | null;
/** `smtp` = sluzba se nevola pres HTTP, adresu nese konektor v udajich. */
transport?: 'http' | 'smtp';
visibility: ServiceVisibility;
credentials: ServiceCredentialField[];
verifyPath?: string;