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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user