Poptavka z webu je ticket, prilohy, udaje provozovatele, kolacovy graf

Provozovatel portalu: firma s priznakem portalOperator (jen jedna, zapnuti
odebere ostatnim) a novymi poli contactEmail, contactPhone, website vedle
ico, dic, adresy a pravni formy. Verejny GET /api/public/brand vraci jeji
udaje a web je bere pres useBrand() na kontaktu, v paticce, O nas,
prihlaseni i v titulku; brand.ts je jen zaloha.

Poptavka z webu zaklada u provozovatele ticket kanalu form: predmet
"Poptavka: tema", telo JSON s poli formulare, tag Poptavka plus tema,
zakaznik z formulare, poznamka v logu. Bez provozovatele se jen zaloguje.

Prilohy ticketu: formular az 3 soubory po 5 MB, ticket az 10; nahrani,
seznam, stazeni a smazani (pravo ticket.comment, strop viditelnosti,
poznamky v logu, audit). Soubor jde v JSON jako Base64 a lezi v beznem
ulozisti, bez nove zavislosti; strop tela jen na techto cestach.

Vlastni widget s kreslenim Graf umi i pocet ticketu se seskupenim jako
kolac (PieChart.tsx, ciste SVG, osm barev z tokenu, zbytek jako ostatni).

OpenAPI rozdelene na mensi soubory (102 cest, 28 schemat overeno shodnych),
28 novych testu (135 celkem), dokumentace aktualizovana.
This commit is contained in:
JiriUhlir
2026-09-09 19:35:00 +02:00
parent 22dda2d139
commit c25e826766
72 changed files with 3978 additions and 1118 deletions
@@ -0,0 +1,216 @@
import { UserCheck } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import type { TicketDetail as Detail } from '@/types/dashboard';
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
*
* Je to nabidka, ne ciselnik - stav je volny retezec a ticket muze mit
* i hodnotu, kterou poslala cizi aplikace.
*/
const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'];
/**
* Karta "Resitel": prevzeti, prirazeni, skupina a stav.
*
* Vsechny zmeny jdou pres `mutate` z detailu, aby chyba a `busy` byly na
* jednom miste se zbytkem stranky. Ciselniky si bere z ulozist sama a
* **nic z nich nebrani otevrit ticket**: kdo nema pravo na lidi, dostane
* kartu bez roletky resitele, ne prazdnou stranku s chybou.
*/
export function TicketAssignPanel({
ticket,
busy,
mutate,
}: {
ticket: Detail;
busy: boolean;
/** POST `/tickets/:id{path}` s telem. Vraci true pri uspechu. */
mutate: (path: string, body: unknown) => Promise<boolean>;
}) {
const people = useCollection('people');
const groups = useCollection('groups');
const types = useCollection('ticketTypes');
const meId = people.extra?.meId ?? null;
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
const [statusesError, setStatusesError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => {
if (!cancelled) setUsedStatuses(data.items);
})
.catch((err: unknown) => {
// Bez naseptavace se da zit, ale musi byt videt, ze chybi a proc.
console.warn('[ticket] stavy se nepodarilo nacist:', err);
if (!cancelled) setStatusesError('Nabídku stavů se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, []);
/*
* Co nabidnout ve vyberu stavu.
*
* Stav je volny retezec, takze nabidka je jen pohodli. Sklada se ze stavu
* typu ticketu (kdyz si je firma nadefinovala), z doporucenych a **z toho,
* co ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const statusChoices = useMemo(() => {
const type = types.items.find((item) => item.id === ticket.typeId);
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
return [...new Set([...base, ...usedStatuses, ticket.status])];
}, [types.items, usedStatuses, ticket.typeId, ticket.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);
return (
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<UserCheck className="size-4 text-brand-300" />
Řešitel
</h2>
<p className={cn('mt-3 text-sm', ticket.assignee ? 'text-white/85' : 'text-warn-400')}>
{ticket.assignee ? ticket.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.
*/}
{meId && ticket.assignee?.id !== meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {})}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
{/*
Bez prava na lidi se roletka nenabidne. Ticket jde otevrit a resit
dal, jen ho nejde predat - a rekne se to.
*/}
{people.error ? (
<p className="mt-3 text-xs text-white/40">
Seznam řešitelů není k dispozici, ticket nejde předat.
</p>
) : (
<>
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<Select
id="ticket-assignee"
value={ticket.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/assign', {
assigneeId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
className="mt-3"
placeholder="Vrátit do fronty"
options={people.items.map((person) => ({
value: person.id,
label: `${person.name}, ${person.role}`,
}))}
/>
</>
)}
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt "u ucetnich"
a zaroven u konkretniho cloveka z nich.
*/}
{groups.items.length > 0 && (
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Skupina</span>}
htmlFor="ticket-group"
className="mt-4"
>
<Select
id="ticket-group"
value={ticket.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/group', {
groupId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
placeholder="Bez skupiny"
options={groups.items.map((group) => ({ value: group.id, label: group.name }))}
/>
</Field>
)}
{/*
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.
*/}
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Stav</span>}
htmlFor="ticket-status"
className="mt-4"
hint={statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'}
>
<Input
id="ticket-status"
list="ticket-status-options"
value={statusDraft ?? ticket.status}
disabled={busy}
onChange={(event) => setStatusDraft(event.target.value)}
onBlur={(event) => {
const next = event.target.value.trim();
setStatusDraft(null);
if (next === '' || next === ticket.status) return;
void mutate('/status', { status: next });
}}
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"
size="lg"
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</Field>
</div>
);
}
@@ -0,0 +1,203 @@
import { Download, Paperclip, Trash2, Upload } from 'lucide-react';
import { useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Button } from '@/components/ui/Button';
import { FilePicker } from '@/components/ui/form/FilePicker';
import { Modal } from '@/components/ui/Modal';
import { apiBlob, apiFetch } from '@/lib/api';
import {
MAX_ATTACHMENT_BYTES,
TICKET_MAX_ATTACHMENTS,
formatBytes,
readFileAsBase64,
} from '@/lib/files';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSubmit } from '@/hooks/useSubmit';
import type { Attachment, AttachmentUpload } from '@/types/dashboard';
/**
* Prilohy ticketu: seznam, stazeni, nahrani a smazani.
*
* Seznam ma vlastni dotaz, ne pole na detailu ticketu: soubory se meni
* nezavisle na ticketu a po nahrani server posle `ticket.updated`, na ktere
* se seznam obnovi sam.
*
* Stazeni jde pres fetch s tokenem a docasny odkaz - obsah chce Authorization
* hlavicku, kterou by prohlizec do obycejneho odkazu nedal.
*/
export function TicketAttachments({
ticketId,
canWrite,
people,
}: {
ticketId: string;
/** Pravo `ticket.comment`: bez nej se nenahrava ani nemaze. */
canWrite: boolean;
/** Jmena k ID uctu ve sloupci "kdo". Bez shody se ukaze ID. */
people: Array<{ id: string; name: string }>;
}) {
const endpoint = `/api/dashboard/tickets/${ticketId}/attachments`;
const list = useApiQuery<{ items: Attachment[] }>(endpoint, { refetchOn: ['ticket.updated'] });
const items = list.data?.items ?? [];
const [files, setFiles] = useState<File[]>([]);
const [toDelete, setToDelete] = useState<Attachment | null>(null);
const upload = useSubmit(async () => {
const payload: AttachmentUpload[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
mime: file.type || 'application/octet-stream',
content: await readFileAsBase64(file),
})),
);
await apiFetch<{ items: Attachment[] }>(endpoint, { method: 'POST', body: { files: payload } });
setFiles([]);
list.reload();
}, 'Přílohy se nepodařilo nahrát.');
const removal = useSubmit(async (attachment: Attachment) => {
await apiFetch<undefined>(`${endpoint}/${attachment.id}`, { method: 'DELETE' });
setToDelete(null);
list.reload();
}, 'Přílohu se nepodařilo smazat.');
const download = useSubmit(async (attachment: Attachment) => {
const { blob, filename } = await apiBlob(`${endpoint}/${attachment.id}/content`);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename ?? attachment.name;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}, 'Soubor se nepodařilo stáhnout.');
const remaining = Math.max(0, TICKET_MAX_ATTACHMENTS - items.length);
const busy = upload.busy || removal.busy || download.busy;
const error = upload.error ?? removal.error ?? download.error;
function who(attachment: Attachment): string {
if (attachment.uploadedBy === null) return 'z webu';
return (
people.find((person) => person.id === attachment.uploadedBy)?.name ?? attachment.uploadedBy
);
}
return (
<section className="glass rounded-card p-5 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
<Paperclip className="size-4 text-brand-300" />
Přílohy
</h2>
<span className="text-xs text-white/35">
{items.length} / {TICKET_MAX_ATTACHMENTS}
</span>
</div>
<div className="mt-3">
<DataState
loading={list.loading}
refreshing={list.refreshing}
error={list.error}
onRetry={list.reload}
empty={items.length === 0}
emptyLabel="Ticket zatím nemá žádnou přílohu."
>
<ul className="divide-y divide-ink-600/40">
{items.map((attachment) => (
<li key={attachment.id} className="flex items-center gap-3 py-2 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate text-white/85">{attachment.name}</p>
<p className="text-xs text-white/40">
{formatBytes(attachment.size)}, {formatDateTime(attachment.createdAt)},{' '}
{who(attachment)}
</p>
</div>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => void download.submit(attachment)}
aria-label={`Stáhnout ${attachment.name}`}
>
<Download className="size-4" />
</Button>
{canWrite && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => setToDelete(attachment)}
aria-label={`Smazat ${attachment.name}`}
>
<Trash2 className="size-4" />
</Button>
)}
</li>
))}
</ul>
</DataState>
</div>
{canWrite && (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
<FilePicker
label={
<span className="text-xs font-semibold tracking-wide uppercase">Přidat přílohy</span>
}
hint={
remaining === 0
? 'Ticket už má nejvyšší počet příloh.'
: `Ještě ${remaining}, každá do ${formatBytes(MAX_ATTACHMENT_BYTES)}.`
}
files={files}
onChange={setFiles}
maxCount={remaining}
disabled={busy}
/>
{files.length > 0 && (
<Button size="sm" disabled={busy} onClick={() => void upload.submit()}>
<Upload className="size-4" />
{upload.busy ? 'Nahrávám...' : 'Nahrát'}
</Button>
)}
</div>
)}
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<Modal
open={toDelete !== null}
onClose={() => setToDelete(null)}
title="Smazat přílohu"
description={toDelete ? `${toDelete.name} zmizí nenávratně.` : undefined}
className="max-w-md"
>
<div className="flex gap-3 p-5">
<Button
size="sm"
disabled={removal.busy}
onClick={() => {
if (toDelete) void removal.submit(toDelete);
}}
>
<Trash2 className="size-4" />
Smazat
</Button>
<Button
size="sm"
variant="secondary"
disabled={removal.busy}
onClick={() => setToDelete(null)}
>
Zrušit
</Button>
</div>
</Modal>
</section>
);
}
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { AresTenantDialog } from '@/components/dashboard/AresTenantDialog';
import { EntityAdmin, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { useApiQuery } from '@/hooks/useApiQuery';
/**
* Pole firmy, u kterych prazdny retezec znamena "nevyplneno".
*
* Prazdne IC neni "IC s nula znaky", ale zadne IC. Server ma na vyplnene
* osm cislic, tak se prazdne posila jako null. Totez u kontaktu a webu.
*/
const NULLABLE_TENANT_FIELDS = [
'ico',
'dic',
'address',
'legalForm',
'contactEmail',
'contactPhone',
'website',
'helpdeskProviderId',
];
/**
* Zalozka Firmy v nastaveni: tabulka, formular a zalozeni pres ARES.
*
* Stoji na `EntityAdmin`, tady je jen to, co je u firmy jine: sloupce, pole
* a prevod prazdnych hodnot na null. Bylo to v `Settings.tsx`, ale s poli
* provozovatele a kontaktu uz se tam neveslo.
*/
export function TenantsAdmin() {
/*
* 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 [aresOpen, setAresOpen] = useState(false);
return (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
refetchOn={['tenant.created', 'tenant.updated', 'tenant.deleted']}
toBody={(values) => ({
...Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
NULLABLE_TENANT_FIELDS.includes(key) && value === '' ? null : value,
]),
),
portalOperator: values.portalOperator === true,
})}
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{
label: 'IČ',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.ico ? (
<code>{String(tenant.ico)}</code>
) : (
<span className="text-white/30">bez IČ</span>
),
},
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
{
label: 'E-mail',
render: (tenant: NamedRecord) =>
tenant.contactEmail ? (
String(tenant.contactEmail)
) : (
<span className="text-white/30">-</span>
),
},
{ 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,
render: (tenant: NamedRecord) => (
<span className="flex flex-wrap gap-1">
{tenant.enabled === false ? (
<Badge tone="warn">vypnutá</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
)}
{tenant.portalOperator === true && <Badge tone="brand">provozovatel</Badge>}
</span>
),
},
]}
fields={[
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
{
name: 'ico',
label: 'IČ',
kind: 'text',
hint: '8 číslic. Vyplní se samo při založení přes ARES.',
},
{ name: 'dic', label: 'DIČ', kind: 'text' },
{ name: 'address', label: 'Sídlo', kind: 'text' },
{ name: 'legalForm', label: 'Právní forma', kind: 'text' },
{ name: 'contactEmail', label: 'E-mail', kind: 'text' },
{ name: 'contactPhone', label: 'Telefon', kind: 'text' },
{ name: 'website', label: 'Web', kind: 'text' },
{
name: 'note',
label: 'Poznámka',
kind: 'textarea',
hint: 'Pro nás, klient ji nevidí.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
/*
Jedna firma je provozovatel: jeji kontakty se ukazuji na verejnem
webu a poptavky z formulare se ji zakladaji jako tickety. Server
pri zapnuti odebere priznak ostatnim, tady se nic nehlida.
*/
name: 'portalOperator',
label: 'Provozovatel portálu',
kind: 'checkbox',
hint: 'Sem chodí poptávky z webu jako tickety. Může být jen jedna firma.',
},
{
/*
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,
})),
],
},
]}
>
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-white/10 p-3 text-sm text-white/70">
<span>
Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé, kteří za
ni jednají.
</span>
<Button size="sm" variant="secondary" onClick={() => setAresOpen(true)}>
Založit přes ARES
</Button>
</div>
<AresTenantDialog
open={aresOpen}
onClose={() => setAresOpen(false)}
onCreated={() => tenants.reload()}
/>
</EntityAdmin>
);
}
@@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { PieChart } from '@/components/dashboard/widgets/PieChart';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
@@ -93,10 +94,14 @@ export function CustomWidgetCard({
}
if (value.kind === 'groups') {
// Seskupeni se kresli jako sloupce (tabulka) nebo kolac (graf).
// Stejna data, jina otazka: sloupce srovnavaji, kolac ukazuje podily.
return (
<Shell definition={definition}>
{value.rows.length === 0 ? (
<p className="text-sm text-white/40">Nic, co by sem patřilo.</p>
) : definition.render === 'chart' ? (
<PieChart rows={value.rows} />
) : (
<Bars rows={value.rows} />
)}
@@ -0,0 +1,152 @@
import { Link } from 'react-router-dom';
import { cn } from '@/lib/cn';
import { formatNumber } from '@/lib/format';
/**
* Kolacovy graf nad seskupenymi hodnotami (tickety podle stavu, kanalu,
* resitele). Kresli se jako SVG vysece, zadna knihovna: osm barev z tokenu
* palety, zbytek nad osm skupin se slouci do "ostatni", protoze vic vysecu
* uz nikdo nerozezna. Legenda vedle grafu nese cislo i podil.
*/
/** Kolik vysecu nejvys. Dal uz je kolac necitelny. */
const MAX_SLICES = 8;
/** Barvy vysecu z tokenu palety, v poradi, ve kterem se pridavaji. */
const SLICE_COLORS = [
'var(--color-brand-400)',
'var(--color-ok-400)',
'var(--color-warn-400)',
'var(--color-danger-400)',
'var(--color-brand-600)',
'var(--color-ok-500)',
'var(--color-warn-500)',
'var(--color-danger-500)',
];
/** Kdyby paleta byla prazdna; typ pole to nevylucuje. */
const FALLBACK_COLOR = 'var(--color-brand-400)';
const OTHER_KEY = '__other__';
interface PieRow {
key: string;
label: string;
value: number;
href?: string;
}
/** Sloz vysece: nejvetsi napred, zbytek nad strop do jedne polozky. */
function slicesOf(rows: PieRow[]): PieRow[] {
const sorted = [...rows].filter((row) => row.value > 0).sort((a, b) => b.value - a.value);
if (sorted.length <= MAX_SLICES) return sorted;
const shown = sorted.slice(0, MAX_SLICES - 1);
const rest = sorted.slice(MAX_SLICES - 1).reduce((sum, row) => sum + row.value, 0);
return [...shown, { key: OTHER_KEY, label: 'ostatní', value: rest }];
}
/** Bod na kruznici pro dany podil (0 az 1), zacatek nahore. */
function pointAt(fraction: number, radius: number): [number, number] {
const angle = fraction * 2 * Math.PI - Math.PI / 2;
return [radius * Math.cos(angle), radius * Math.sin(angle)];
}
/** Cesta jedne vysece od podilu `from` do `to`. */
function arcPath(from: number, to: number, radius: number): string {
const [x1, y1] = pointAt(from, radius);
const [x2, y2] = pointAt(to, radius);
const large = to - from > 0.5 ? 1 : 0;
return `M 0 0 L ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} Z`;
}
interface Shape {
row: PieRow;
from: number;
to: number;
color: string;
}
/** Rozlozi vysece po obvodu: kazda zacina tam, kde predchozi skoncila. */
function layoutSlices(slices: PieRow[], total: number): Shape[] {
const shapes: Shape[] = [];
let cursor = 0;
for (const [index, row] of slices.entries()) {
const from = cursor;
cursor += row.value / total;
shapes.push({
row,
from,
to: cursor,
color: SLICE_COLORS[index % SLICE_COLORS.length] ?? FALLBACK_COLOR,
});
}
return shapes;
}
export function PieChart({ rows }: { rows: PieRow[] }) {
const slices = slicesOf(rows);
const total = slices.reduce((sum, row) => sum + row.value, 0);
if (total === 0) {
return <p className="text-sm text-white/40">Nic, co by sem patřilo.</p>;
}
const radius = 48;
const shapes = layoutSlices(slices, total);
return (
<div className="flex flex-wrap items-center gap-5">
<svg
viewBox="-50 -50 100 100"
className="size-32 shrink-0"
role="img"
aria-label={`Koláčový graf, celkem ${formatNumber(total)}`}
>
{shapes.map((shape) =>
// Jedina vysec je cely kruh, oblouk by se sam do sebe nezavrel.
shapes.length === 1 ? (
<circle key={shape.row.key} r={radius} fill={shape.color} />
) : (
<path
key={shape.row.key}
d={arcPath(shape.from, shape.to, radius)}
fill={shape.color}
stroke="var(--color-ink-900)"
strokeWidth="1"
/>
),
)}
</svg>
<ul className="min-w-0 flex-1 space-y-1.5 text-sm">
{shapes.map((shape) => {
const share = Math.round((shape.row.value / total) * 100);
const body = (
<>
<span
className="inline-block size-2.5 shrink-0 rounded-full"
style={{ background: shape.color }}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-white/70 group-hover:text-brand-200">
{shape.row.label}
</span>
<span className="font-semibold text-white">{formatNumber(shape.row.value)}</span>
<span className="w-10 text-right text-xs text-white/40">{share} %</span>
</>
);
return (
<li key={shape.row.key} className={cn('flex items-center gap-2')}>
{shape.row.href ? (
<Link to={shape.row.href} className="group flex min-w-0 flex-1 items-center gap-2">
{body}
</Link>
) : (
body
)}
</li>
);
})}
</ul>
</div>
);
}
+5 -4
View File
@@ -1,12 +1,13 @@
import { ArrowRight, Mail, Phone } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { useT } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
/** Zaverecna vyzva k akci pred paticku. */
export function CallToAction() {
const t = useT();
const operator = useBrand();
return (
<section className="py-20 sm:py-24">
@@ -26,15 +27,15 @@ export function CallToAction() {
{t('cta.write')}
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to={`tel:${brand.phoneHref}`} variant="secondary" size="lg">
<ButtonLink to={`tel:${operator.phoneHref}`} variant="secondary" size="lg">
<Phone className="size-4" />
{brand.phone}
{operator.phone}
</ButtonLink>
</div>
<p className="relative mt-6 inline-flex items-center gap-2 text-sm text-white/45">
<Mail className="size-4" />
{brand.email}
{operator.email}
</p>
</div>
</Container>
+14 -9
View File
@@ -2,12 +2,13 @@ import { Mail, MapPin, Phone } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Logo } from '@/components/layout/Logo';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { footerNav } from '@/data/navigation';
import { useT } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
export function Footer() {
const t = useT();
const operator = useBrand();
const year = new Date().getFullYear();
return (
@@ -44,28 +45,32 @@ export function Footer() {
<ul className="space-y-3 text-sm text-white/55">
<li>
<a
href={`mailto:${brand.email}`}
href={`mailto:${operator.email}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Mail className="size-4 shrink-0 text-brand-400" />
{brand.email}
{operator.email}
</a>
</li>
<li>
<a
href={`tel:${brand.phoneHref}`}
href={`tel:${operator.phoneHref}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Phone className="size-4 shrink-0 text-brand-400" />
{brand.phone}
{operator.phone}
</a>
</li>
<li className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4 shrink-0 text-brand-400" />
<span>
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
{/* Bez zeme: v paticce staci ulice a mesto. */}
{operator.addressLines.slice(0, 2).map((line, index) => (
<span key={line}>
{index > 0 && <br />}
{line}
</span>
))}
</span>
</li>
</ul>
@@ -74,7 +79,7 @@ export function Footer() {
<div className="mt-12 flex flex-col gap-3 border-t border-ink-600/50 pt-6 text-xs text-white/40 sm:flex-row sm:items-center sm:justify-between">
<p>
© {year} {brand.legalName} · IČO {brand.ico}
© {year} {operator.legalName} · IČO {operator.ico}
</p>
<p className="font-mono">{t('footer.prototype')}</p>
</div>
+4 -1
View File
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
import { useBrand } from '@/hooks/useBrand';
/**
* Znacka: plochy znak plus slovni znacka.
@@ -48,12 +49,14 @@ export function Logo({
/** Podtitulek pod slovni znackou. Jen tam, kde je na nej misto. */
withClaim?: boolean;
}) {
const { name } = useBrand();
return (
<Link to={to} className={cn('group inline-flex items-center gap-2.5', className)}>
<LogoMark className="size-8 text-brand-400 transition-transform duration-300 group-hover:-translate-y-0.5" />
<span className="flex flex-col leading-none">
<span className="font-display text-lg font-extrabold tracking-tight text-white">
{brand.name}
{name}
</span>
{withClaim && (
<span className="mt-1 font-mono text-[0.6rem] tracking-[0.22em] text-white/40 uppercase">
+121
View File
@@ -0,0 +1,121 @@
import { Paperclip, X } from 'lucide-react';
import { useId, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { formatBytes, MAX_ATTACHMENT_BYTES, validateFiles } from '@/lib/files';
/**
* Vyber priloh: tlacitko, skryty `<input type="file">` a seznam vybranych.
*
* Nativni vstup se nekresli, protoze se neda ostylovat jednotne s ostatnimi
* poli a jeho popisek "Soubor nevybran" se neda prelozit. Vybrane soubory
* drzi rodic (`files` / `onChange`), komponenta jen pridava a odebira.
*
* Kontrola poctu a velikosti bezi pri vyberu: kdo vybere prilis, dostane
* chybu hned a vyber se nepouzije. `error` zvenku ma prednost, tam chodi
* odpoved serveru.
*/
export function FilePicker({
label,
hint,
files,
onChange,
maxCount,
maxBytes = MAX_ATTACHMENT_BYTES,
error,
disabled = false,
size = 'sm',
buttonLabel = 'Vybrat soubory',
accept,
}: {
label: ReactNode;
hint?: ReactNode;
files: File[];
onChange: (files: File[]) => void;
/** Kolik souboru smi byt vybrano celkem. */
maxCount: number;
maxBytes?: number;
/** Chyba zvenku, napr. ze serveru. Prebije chybu z vyberu. */
error?: string | null;
disabled?: boolean;
size?: 'sm' | 'md';
/** Text tlacitka. Verejny web ho preklada. */
buttonLabel?: string;
/** Omezeni typu pro dialog prohlizece, napr. `image/*,.pdf`. */
accept?: string;
}) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [pickError, setPickError] = useState<string | null>(null);
function pick(list: FileList | null) {
if (!list || list.length === 0) return;
const next = [...files, ...Array.from(list)];
const problem = validateFiles(next, { maxCount, maxBytes });
setPickError(problem);
if (!problem) onChange(next);
// Vynulovat, aby sel tentyz soubor vybrat znovu po odebrani.
if (inputRef.current) inputRef.current.value = '';
}
function remove(index: number) {
setPickError(null);
onChange(files.filter((_, position) => position !== index));
}
const full = files.length >= maxCount;
return (
<Field label={label} hint={hint} error={error ?? pickError} as="div" size={size}>
<input
ref={inputRef}
id={inputId}
type="file"
multiple
accept={accept}
className="sr-only"
disabled={disabled || full}
onChange={(event) => pick(event.target.files)}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
size="sm"
variant="secondary"
disabled={disabled || full}
onClick={() => inputRef.current?.click()}
>
<Paperclip className="size-4" />
{buttonLabel}
</Button>
<span className="text-xs text-white/35">
{files.length} / {maxCount}
</span>
</div>
{files.length > 0 && (
<ul className="mt-2 space-y-1">
{files.map((file, index) => (
<li
key={`${file.name}-${file.size}-${index}`}
className="flex items-center gap-2 rounded-lg border border-ink-600/50 bg-ink-900/40 px-3 py-1.5 text-sm"
>
<span className="min-w-0 flex-1 truncate text-white/80">{file.name}</span>
<span className="shrink-0 text-xs text-white/40">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => remove(index)}
disabled={disabled}
className="grid size-6 shrink-0 place-items-center rounded text-white/40 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-50"
aria-label={`Odebrat ${file.name}`}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</Field>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useSyncExternalStore } from 'react';
import { brand } from '@/config/brand';
import { apiFetch } from '@/lib/api';
import type { PublicBrand } from '@/types/dashboard';
/**
* Kontaktni a fakturacni udaje pro verejny web.
*
* Zdrojem je firma oznacena jako provozovatel portalu (`Tenant.portalOperator`),
* server ji vraci z `/api/public/brand` bez prihlaseni. `config/brand.ts` je
* jen zaloha: kdyz server posle `null` nebo neodpovi, zustane staticka hodnota.
*
* Nacita se jednou za nacteni stranky a sdili mezi vsemi komponentami:
* paticka, kontakt i vyzva k akci by jinak poslaly tri stejne dotazy.
* Staticke hodnoty se vykresli hned, po odpovedi se jen vymeni.
*/
export interface BrandInfo {
name: string;
legalName: string;
email: string;
phone: string;
/** Telefon bez mezer do `tel:` odkazu. */
phoneHref: string;
/**
* Adresa po radcich. Firma z portalu ma jednu textovou radku, staticka
* zaloha tri (ulice, PSC a mesto, zeme).
*/
addressLines: string[];
ico: string;
dic: string;
/** Web firmy. null = neni odkud vzit, staticka zaloha ho nema. */
website: string | null;
}
const fallback: BrandInfo = {
name: brand.name,
legalName: brand.legalName,
email: brand.email,
phone: brand.phone,
phoneHref: brand.phoneHref,
addressLines: [
brand.address.street,
`${brand.address.zip} ${brand.address.city}`,
brand.address.country,
],
ico: brand.ico,
dic: brand.dic,
website: null,
};
function merge(server: PublicBrand): BrandInfo {
const phone = server.phone ?? fallback.phone;
return {
name: server.name ?? fallback.name,
legalName: server.legalName ?? fallback.legalName,
email: server.email ?? fallback.email,
phone,
phoneHref: phone.replace(/\s+/g, ''),
addressLines: server.address ? [server.address] : fallback.addressLines,
ico: server.ico ?? fallback.ico,
dic: server.dic ?? fallback.dic,
website: server.website,
};
}
// ---------------------------------------------------------------- mezipamet
let current: BrandInfo = fallback;
let inflight: Promise<void> | null = null;
const listeners = new Set<() => void>();
function load(): Promise<void> {
if (inflight) return inflight;
inflight = apiFetch<PublicBrand>('/api/public/brand', { auth: false })
.then((server) => {
current = merge(server);
for (const listener of listeners) listener();
})
.catch((err: unknown) => {
// Zaloha zustava, web bez udaju provozovatele porad funguje.
console.warn('[brand] udaje provozovatele se nepodarilo nacist:', err);
});
return inflight;
}
/** Odber pro `useSyncExternalStore`. Prvni odberatel spusti nacteni. */
function subscribe(listener: () => void): () => void {
listeners.add(listener);
void load();
return () => {
listeners.delete(listener);
};
}
function snapshot(): BrandInfo {
return current;
}
/** Udaje provozovatele. Vraci hned zalohu, po nacteni se prekresli. */
export function useBrand(): BrandInfo {
return useSyncExternalStore(subscribe, snapshot, snapshot);
}
+6 -3
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { brand } from '@/config/brand';
import { useBrand } from '@/hooks/useBrand';
/**
* Nastavi <title> a meta description pro danou stranku.
@@ -21,11 +22,13 @@ export function usePageMeta({
/** `portal` prida slovo "portal" - stranky za prihlasenim. */
area?: 'portal';
}) {
// Jmeno bere od provozovatele portalu, claim zustava staticky.
const { name } = useBrand();
const fullTitle = !title
? `${brand.name} - ${brand.claim}`
? `${name} - ${brand.claim}`
: area === 'portal'
? `${title} - portál ${brand.name}`
: `${title} - ${brand.name}`;
? `${title} - portál ${name}`
: `${title} - ${name}`;
useEffect(() => {
document.title = fullTitle;
+4
View File
@@ -319,6 +319,10 @@ export const cs = {
'contact.submitting': 'Odesílám...',
'contact.consent': 'Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.',
'contact.failed': 'Zprávu se nepodařilo odeslat.',
'contact.attachments': 'Přílohy',
'contact.attachmentsHint': 'Nejvíc {count} soubory, každý do {size}.',
'contact.pickFiles': 'Vybrat soubory',
'contact.website': 'Web',
'contact.directTitle': 'Přímé kontakty',
'contact.availability': 'Dostupnost',
'contact.address': 'Adresa',
+4
View File
@@ -321,6 +321,10 @@ export const en: Partial<Record<MessageKey, string>> = {
'contact.consent':
'By sending you agree to the processing of your data for the purpose of a reply.',
'contact.failed': 'The message could not be sent.',
'contact.attachments': 'Attachments',
'contact.attachmentsHint': 'Up to {count} files, each up to {size}.',
'contact.pickFiles': 'Choose files',
'contact.website': 'Website',
'contact.directTitle': 'Direct contacts',
'contact.availability': 'Availability',
'contact.address': 'Address',
+53
View File
@@ -171,3 +171,56 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
const result = await apiFetchWithMeta<T>(path, options);
return result.data;
}
/** Binarni odpoved: obsah a nazev souboru z `Content-Disposition`. */
export interface ApiBlob {
blob: Blob;
/** Nazev z hlavicky. null = server ho neposlal, volajici si dosadi svuj. */
filename: string | null;
}
/**
* Stazeni souboru s tokenem.
*
* Obsah prilohy chce Authorization hlavicku, takze obycejny `<a href>` nestaci:
* prohlizec ji do navigace nepridava. Stahne se pres fetch a volajici z blobu
* udela docasny odkaz. Chyby se zpracuji stejne jako u JSONu.
*/
export async function apiBlob(path: string): Promise<ApiBlob> {
const token = getToken();
const target = withTenant(path);
const response = await fetch(apiUrl(target), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload: unknown = isJson ? await response.json().catch(() => null) : null;
const message =
payload !== null && typeof payload === 'object' && 'message' in payload
? String((payload as { message: unknown }).message)
: `Soubor se nepodařilo stáhnout (HTTP ${response.status}).`;
console.error(`[api] ${path} -> ${response.status} ${message}`);
if (response.status === 401 && token) notifyAuthExpired();
throw new ApiError(message, response.status);
}
return { blob: await response.blob(), filename: filenameFrom(response.headers) };
}
/** Nazev z `Content-Disposition`, prednostne z `filename*` (UTF-8). */
function filenameFrom(headers: Headers): string | null {
const disposition = headers.get('content-disposition');
if (!disposition) return null;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
if (encoded?.[1]) {
try {
return decodeURIComponent(encoded[1]);
} catch (err) {
console.warn('[api] nazev souboru v hlavicce nejde dekodovat:', err);
}
}
const plain = /filename="?([^";]+)"?/i.exec(disposition);
return plain?.[1] ?? null;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Prace se soubory pred odeslanim: prevod na base64, velikost, kontrola.
*
* Limity musi sedet se serverem (`MAX_ATTACHMENT_BYTES` v routach priloh).
* Kontrola na klientovi je jen pohodli - server ji dela znovu.
*/
import { plural } from '@/lib/format';
/** Nejvetsi povolena priloha. Stejna hodnota je na serveru. */
export const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
/** Kolik priloh jde poslat s poptavkou z webu. */
export const CONTACT_MAX_ATTACHMENTS = 3;
/** Kolik priloh muze mit jeden ticket celkem. */
export const TICKET_MAX_ATTACHMENTS = 10;
const KILOBYTE = 1024;
const MEGABYTE = KILOBYTE * 1024;
/** "12 kB" / "1,4 MB". Pod kilobajt se pise v bajtech. */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-';
if (bytes < KILOBYTE) return `${bytes} B`;
if (bytes < MEGABYTE) return `${Math.round(bytes / KILOBYTE)} kB`;
return `${(bytes / MEGABYTE).toFixed(1).replace('.', ',')} MB`;
}
/**
* Obsah souboru jako base64 bez prefixu `data:...;base64,`.
*
* Server dostane jen data; typ a nazev jdou vedle v JSONu, takze prefix by
* byl duplicitni a jeste by ho musel na serveru nekdo odriznout.
*/
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => {
console.error('[files] cteni souboru selhalo:', file.name, reader.error);
reject(reader.error ?? new Error(`Soubor ${file.name} se nepodařilo přečíst.`));
};
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const comma = result.indexOf(',');
resolve(comma === -1 ? result : result.slice(comma + 1));
};
reader.readAsDataURL(file);
});
}
/**
* Kontrola pred odeslanim: pocet a velikost. Vraci ceskou chybu, nebo null.
*
* `maxCount` je kolik jeste jde pridat (u ticketu limit minus uz nahrane),
* ne absolutni strop - to si spocita volajici.
*/
export function validateFiles(
files: File[],
{ maxCount, maxBytes }: { maxCount: number; maxBytes: number },
): string | null {
if (files.length > maxCount) {
return maxCount === 0
? 'Další přílohy už přidat nejde.'
: `Nejvíc ${maxCount} ${plural(maxCount, ['soubor', 'soubory', 'souborů'])}.`;
}
const oversized = files.find((file) => file.size > maxBytes);
if (oversized) {
return `Soubor ${oversized.name} je větší než ${formatBytes(maxBytes)}.`;
}
const empty = files.find((file) => file.size === 0);
if (empty) return `Soubor ${empty.name} je prázdný.`;
return null;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/ui/PageHeader';
import { Section, SectionHeading } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
import { usePageMeta } from '@/hooks/usePageMeta';
const values: Array<{ icon: typeof Compass; title: MessageKey; text: MessageKey }> = [
@@ -31,6 +32,7 @@ const milestones: Array<{ year: string; text: MessageKey }> = [
export default function About() {
const t = useT();
const operator = useBrand();
usePageMeta({ title: t('about.title'), description: t('about.meta') });
@@ -43,7 +45,7 @@ export default function About() {
{t('about.titleFirst')} <span className="text-gradient">{t('about.titleAccent')}</span>
</>
}
subtitle={t('about.subtitle', { brand: brand.name, founded: brand.founded })}
subtitle={t('about.subtitle', { brand: operator.name, founded: brand.founded })}
/>
<Section>
+68 -14
View File
@@ -1,10 +1,11 @@
import { AlertCircle, CheckCircle2, Clock, Mail, MapPin, Phone, Send } from 'lucide-react';
import { AlertCircle, CheckCircle2, Clock, Globe, Mail, MapPin, Phone, Send } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Field } from '@/components/ui/form/Field';
import { FilePicker } from '@/components/ui/form/FilePicker';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
@@ -13,7 +14,15 @@ import { Section } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { apiFetch } from '@/lib/api';
import {
CONTACT_MAX_ATTACHMENTS,
MAX_ATTACHMENT_BYTES,
formatBytes,
readFileAsBase64,
} from '@/lib/files';
import { useBrand } from '@/hooks/useBrand';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { AttachmentUpload } from '@/types/dashboard';
/** Hodnota jde na server cesky, popisek se preklada. */
const topics: Array<{ value: string; label: MessageKey }> = [
@@ -33,10 +42,12 @@ type Status =
export default function Contact() {
const t = useT();
const operator = useBrand();
usePageMeta({ title: t('contact.title'), description: t('contact.meta') });
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const [files, setFiles] = useState<File[]>([]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -45,13 +56,23 @@ export default function Contact() {
setStatus({ kind: 'sending' });
try {
// Prilohy jdou v tele jako base64. Formular je maly a limit 3 x 5 MB
// se do JSONu vejde, multipart by znamenal druhy zpusob parsovani.
const attachments: AttachmentUpload[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
mime: file.type || 'application/octet-stream',
content: await readFileAsBase64(file),
})),
);
const response = await apiFetch<{ message: string }>('/api/contact', {
method: 'POST',
auth: false,
body: data,
body: { ...data, attachments },
});
setStatus({ kind: 'ok', message: response.message });
form.reset();
setFiles([]);
} catch (err) {
const message = err instanceof Error ? err.message : t('contact.failed');
console.error('[contact] odeslani selhalo:', err);
@@ -140,6 +161,20 @@ export default function Contact() {
/>
</Field>
<FilePicker
label={t('contact.attachments')}
hint={t('contact.attachmentsHint', {
count: CONTACT_MAX_ATTACHMENTS,
size: formatBytes(MAX_ATTACHMENT_BYTES),
})}
buttonLabel={t('contact.pickFiles')}
files={files}
onChange={setFiles}
maxCount={CONTACT_MAX_ATTACHMENTS}
disabled={status.kind === 'sending'}
size="md"
/>
<div className="flex flex-wrap items-center gap-4 pt-2">
<Button type="submit" disabled={status.kind === 'sending'}>
<Send className="size-4" />
@@ -168,26 +203,45 @@ export default function Contact() {
<h2 className="font-semibold text-white">{t('contact.directTitle')}</h2>
<ul className="mt-4 space-y-4 text-sm">
<ContactRow icon={<Mail className="size-4" />} label={t('contact.email')}>
<a href={`mailto:${brand.email}`} className="text-white/70 hover:text-brand-300">
{brand.email}
<a
href={`mailto:${operator.email}`}
className="text-white/70 hover:text-brand-300"
>
{operator.email}
</a>
</ContactRow>
<ContactRow icon={<Phone className="size-4" />} label={t('contact.phone')}>
<a href={`tel:${brand.phoneHref}`} className="text-white/70 hover:text-brand-300">
{brand.phone}
<a
href={`tel:${operator.phoneHref}`}
className="text-white/70 hover:text-brand-300"
>
{operator.phone}
</a>
</ContactRow>
{operator.website && (
<ContactRow icon={<Globe className="size-4" />} label={t('contact.website')}>
<a
href={operator.website}
target="_blank"
rel="noreferrer"
className="text-white/70 hover:text-brand-300"
>
{operator.website}
</a>
</ContactRow>
)}
<ContactRow icon={<Clock className="size-4" />} label={t('contact.availability')}>
<span className="text-white/70">{brand.support.hours}</span>
<span className="mt-0.5 block text-xs text-white/40">{brand.support.sla}</span>
</ContactRow>
<ContactRow icon={<MapPin className="size-4" />} label={t('contact.address')}>
<span className="text-white/70">
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
<br />
{brand.address.country}
{operator.addressLines.map((line, index) => (
<span key={line}>
{index > 0 && <br />}
{line}
</span>
))}
</span>
</ContactRow>
</ul>
@@ -198,15 +252,15 @@ export default function Contact() {
<dl className="mt-4 space-y-2 font-mono text-sm text-white/60">
<div className="flex justify-between gap-4">
<dt className="text-white/40">{t('contact.billingCompany')}</dt>
<dd>{brand.legalName}</dd>
<dd>{operator.legalName}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">{t('contact.billingIco')}</dt>
<dd>{brand.ico}</dd>
<dd>{operator.ico}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">{t('contact.billingDic')}</dt>
<dd>{brand.dic}</dd>
<dd>{operator.dic}</dd>
</div>
</dl>
</Card>
+4 -3
View File
@@ -7,8 +7,8 @@ import { Logo } from '@/components/layout/Logo';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
import { usePageMeta } from '@/hooks/usePageMeta';
/**
@@ -35,10 +35,11 @@ const [defaultAccount] = demoAccounts;
/** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */
export default function Login() {
const t = useT();
const operator = useBrand();
usePageMeta({
title: t('login.title'),
description: t('login.meta', { brand: brand.name }),
description: t('login.meta', { brand: operator.name }),
});
const { user, loading, login } = useAuth();
@@ -195,7 +196,7 @@ export default function Login() {
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
{t('login.contactUs')}
</Link>{' '}
{t('login.noAccountTail', { brand: brand.name })}
{t('login.noAccountTail', { brand: operator.name })}
</p>
</div>
</div>
+2 -119
View File
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { AresTenantDialog } from '@/components/dashboard/AresTenantDialog';
import { EntityAdmin, parseJsonField, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -15,6 +14,7 @@ import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import { FeaturesAdmin } from '@/components/dashboard/settings/FeaturesAdmin';
import { AuditView } from '@/components/dashboard/settings/AuditView';
import { TenantsAdmin } from '@/components/dashboard/settings/TenantsAdmin';
import type { SettingsCatalog } from '@/components/dashboard/settings/types';
/** Nejkratsi heslo. Musi sedet s validaci na serveru. */
@@ -38,17 +38,7 @@ export default function Settings() {
const { user } = useAuth();
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 [aresOpen, setAresOpen] = useState(false);
/*
* Vsechny zalozky krome uctu jsou sprava platformy, ne firmy. Priznak
@@ -94,114 +84,7 @@ export default function Settings() {
{tab === 'users' && <UsersAdmin />}
{tab === 'tenants' && (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
refetchOn={['tenant.created', 'tenant.updated', 'tenant.deleted']}
// Prazdne IC neni "IC s nula znaky", ale zadne IC. Server ma na
// vyplnene osm cislic, tak se prazdne posila jako null.
toBody={(values) =>
Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
['ico', 'dic', 'address', 'legalForm', 'helpdeskProviderId'].includes(key) &&
value === ''
? null
: value,
]),
)
}
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{
label: 'IČ',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.ico ? (
<code>{String(tenant.ico)}</code>
) : (
<span className="text-white/30">bez IČ</span>
),
},
{ 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,
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: 'ico',
label: 'IČ',
kind: 'text',
hint: '8 číslic. Vyplní se samo při založení přes ARES.',
},
{ name: 'dic', label: 'DIČ', kind: 'text' },
{ name: 'address', label: 'Sídlo', kind: 'text' },
{ name: 'legalForm', label: 'Právní forma', kind: 'text' },
{
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,
})),
],
},
]}
>
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-white/10 p-3 text-sm text-white/70">
<span>
Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé,
kteří za ni jednají.
</span>
<Button size="sm" variant="secondary" onClick={() => setAresOpen(true)}>
Založit přes ARES
</Button>
</div>
<AresTenantDialog
open={aresOpen}
onClose={() => setAresOpen(false)}
onCreated={() => tenants.reload()}
/>
</EntityAdmin>
)}
{tab === 'tenants' && <TenantsAdmin />}
{tab === 'features' && <FeaturesAdmin catalog={catalog.data} />}
{tab === 'audit' && <AuditView />}
+18 -206
View File
@@ -6,9 +6,8 @@ import {
Inbox,
MessageSquarePlus,
ScrollText,
UserCheck,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
@@ -18,17 +17,17 @@ import {
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketActions } from '@/components/dashboard/TicketActions';
import { TicketAssignPanel } from '@/components/dashboard/TicketAssignPanel';
import { TicketAttachments } from '@/components/dashboard/TicketAttachments';
import { DataTable, TicketBody } from '@/components/dashboard/TicketBody';
import { TicketEvents } from '@/components/dashboard/TicketEvents';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import { useAccess, useCollection } from '@/lib/collections';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
@@ -38,14 +37,6 @@ import type { TicketDetail as Detail } from '@/types/dashboard';
/** Nejkratsi komentar, ktery se odesle. Jedno pismeno je preklep, ne zprava. */
const MIN_COMMENT_LENGTH = 2;
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
*
* Je to nabidka, ne ciselnik - stav je volny retezec a ticket muze mit
* i hodnotu, kterou poslala cizi aplikace.
*/
const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'];
export default function TicketDetail() {
const { id = '' } = useParams();
@@ -55,61 +46,20 @@ export default function TicketDetail() {
/*
* Ciselniky z ulozist, ne vlastni dotazy. A hlavne **nic z nich nebrani
* otevrit ticket**: kdo nema pravo na lidi nebo katalog, dostane detail
* bez roletky resitele, ne prazdnou stranku s chybou.
* otevrit ticket**: kdo nema pravo na katalog, dostane log s ID sluzeb
* misto jmen, ne prazdnou stranku s chybou. Resitele a stav resi
* `TicketAssignPanel`, ten si sve ciselniky bere sam.
*/
const people = useCollection('people');
const groups = useCollection('groups');
const services = useCollection('services');
const types = useCollection('ticketTypes');
const access = useAccess();
/** Nahravat a mazat prilohy smi ten, kdo smi komentovat. */
const canAttach = access.data?.permissions.includes('ticket.comment') === true;
const [comment, setComment] = useState('');
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
const [statusesError, setStatusesError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => {
if (!cancelled) setUsedStatuses(data.items);
})
.catch((err: unknown) => {
// Bez naseptavace se da zit, ale musi byt videt, ze chybi a proc.
console.warn('[ticket] stavy se nepodarilo nacist:', err);
if (!cancelled) setStatusesError('Nabídku stavů se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, []);
/*
* Co nabidnout ve vyberu stavu.
*
* Stav je volny retezec, takze nabidka je jen pohodli. Sklada se ze stavu
* typu ticketu (kdyz si je firma nadefinovala), z doporucenych a **z toho,
* co ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const statusChoices = useMemo(() => {
const type = types.items.find((item) => item.id === ticket.data?.typeId);
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
const current = ticket.data?.status;
return [...new Set([...base, ...usedStatuses, ...(current ? [current] : [])])];
}, [types.items, 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);
/**
* Ktery list detailu je navrchu.
*
@@ -160,8 +110,6 @@ export default function TicketDetail() {
if (await mutate('/comment', { text })) setComment('');
}
const meId = people.extra?.meId ?? null;
return (
<div className="space-y-6">
<Link
@@ -339,152 +287,16 @@ export default function TicketDetail() {
</>
)}
</section>
<TicketAttachments
ticketId={ticket.data.id}
canWrite={canAttach}
people={people.items}
/>
</div>
<aside className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<UserCheck className="size-4 text-brand-300" />
Řešitel
</h2>
<p
className={cn(
'mt-3 text-sm',
ticket.data.assignee ? 'text-white/85' : 'text-warn-400',
)}
>
{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.
*/}
{meId && ticket.data.assignee?.id !== meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {})}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
{/*
Bez prava na lidi se roletka nenabidne. Ticket jde otevrit
a resit dal, jen ho nejde predat - a rekne se to.
*/}
{people.error ? (
<p className="mt-3 text-xs text-white/40">
Seznam řešitelů není k dispozici, ticket nejde předat.
</p>
) : (
<>
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<Select
id="ticket-assignee"
value={ticket.data.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/assign', {
assigneeId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
className="mt-3"
placeholder="Vrátit do fronty"
options={people.items.map((person) => ({
value: person.id,
label: `${person.name}, ${person.role}`,
}))}
/>
</>
)}
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt
"u ucetnich" a zaroven u konkretniho cloveka z nich.
*/}
{groups.items.length > 0 && (
<Field
label={
<span className="text-xs font-semibold tracking-wide uppercase">
Skupina
</span>
}
htmlFor="ticket-group"
className="mt-4"
>
<Select
id="ticket-group"
value={ticket.data.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/group', {
groupId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
placeholder="Bez skupiny"
options={groups.items.map((group) => ({
value: group.id,
label: group.name,
}))}
/>
</Field>
)}
{/*
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.
*/}
<Field
label={
<span className="text-xs font-semibold tracking-wide uppercase">Stav</span>
}
htmlFor="ticket-status"
className="mt-4"
hint={
statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'
}
>
<Input
id="ticket-status"
list="ticket-status-options"
value={statusDraft ?? ticket.data.status}
disabled={busy}
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 });
}}
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"
size="lg"
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</Field>
</div>
<TicketAssignPanel ticket={ticket.data} busy={busy} mutate={mutate} />
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
+4 -3
View File
@@ -32,8 +32,9 @@ interface WidgetRecord {
}
const sourceHint = `Příklady:
{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}
{"kind":"ticketCount","filter":{"closed":false}}
{"kind":"ticketCount","filter":{},"groupBy":"assignee"}
{"kind":"ticketCount","filter":{"closed":false},"groupBy":"status"} (s kreslením Graf = koláč)
{"kind":"ticketList","filter":{"status":["new"]},"limit":5}
{"kind":"ticketSeries","filter":{},"bucket":"day"}
{"kind":"agentStats","period":"30d"}
@@ -57,7 +58,7 @@ export default function Widgets() {
{ value: 'stat', label: 'Jedno číslo' },
{ value: 'table', label: 'Tabulka' },
{ value: 'list', label: 'Seznam' },
{ value: 'chart', label: 'Graf' },
{ value: 'chart', label: 'Graf (křivka z časové řady, koláč ze seskupení)' },
{ value: 'gauge', label: 'Ukazatel k cíli' },
],
},
@@ -83,7 +84,7 @@ export default function Widgets() {
label: 'Odkud data',
kind: 'json',
required: true,
initial: '{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}',
initial: '{"kind":"ticketCount","filter":{"closed":false}}',
hint: sourceHint,
},
{
+2 -1
View File
@@ -6,6 +6,7 @@
* ktere existuji jen na webu.
*/
export type { Attachment, AttachmentUpload } from '@shared/attachments';
export type {
Access,
ModuleKey,
@@ -82,7 +83,7 @@ export type {
ServiceVisibility,
ServiceWithUsage,
} from '@shared/services';
export type { Tenant, TenantRef } from '@shared/tenants';
export type { PublicBrand, Tenant, TenantRef } from '@shared/tenants';
export type {
AgentStatsRow,
Ticket,