Ticket jde uzavrit, komentare jsou videt, zakaznik bez soudu, Dashboard misto Portalu

Karta Resitel ma tlacitko Uzavrit ticket a Znovu otevrit; meni jen priznak
closed, text stavu zustava a meni se v poli Stav nezavisle.

Komentar je vlastni druh radku logu (kind comment) s autorem; detail ma
sekci Komentare se seznamem (jmeno, cas, text) a formularem, log ho ukazuje
s autorem. Test tests/data/comments.test.ts.

Sekce Zakaznik jen popisuje: pryc odznak "nedohledan", varovne zbarveni,
text o CRM a garantovi i zvyrazneni v tabulce a na karte; ID v CRM jen
kdyz existuje.

Uzivatelske texty: Portal a Prehled -> Dashboard (zalozka Prehled v Lidech
je Souhrn), v auditu "odepreni" -> "zamitnute pokusy".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-10 07:56:55 +02:00
co-authored by Claude Fable 5.1
parent 5d41600578
commit 0ae9dab1d4
29 changed files with 296 additions and 136 deletions
+5 -2
View File
@@ -228,8 +228,11 @@ Zapisuje se zanorene (`TraceInput` s `children`) a uklada zplostele s `parentId`
Klient si strom zase poskladá. Zaznam s neznamym rodicem se nezahodi, prida se
do korene a zaloguje - ztratit radek logu je horsi nez ho ukazat spatne zanoreny.
Komentare jsou taky zaznamy logu (`kind: 'note'`). Diky tomu je vsechno na jedne
casove ose a nemusi se nikde skladat dohromady dva ruzne seznamy.
Komentare jsou taky zaznamy logu, s vlastnim druhem `kind: 'comment'`
a polem `author`. Diky tomu je vsechno na jedne casove ose a nemusi se nikde
skladat dohromady dva ruzne seznamy; detail z logu vybere jen komentare do
sekce Komentare (`TicketComments.tsx`) a log je ukazuje s autorem. Starsi
komentare ulozene jako `note` "autor: text" zustavaji jen v logu.
Prilohy do logu zapisuji taky `note`: `Příloha: nazev (velikost)` pri nahrani,
`Příloha odebrána: nazev` pri smazani (`noteAttachment`
+29
View File
@@ -2,6 +2,35 @@
Nejnovejsi nahore.
## 2026-09-10 - Ticket jde uzavrit, komentare jsou videt, zakaznik bez soudu, Dashboard misto Portalu
Pet veci z pouzivani:
- **Uzavreni ticketu.** Stav byl volny text a priznak `closed` se z detailu
nedal nastavit, takze ticket slo "vyresit" jen automatizaci. Karta Resitel
ma tlacitko Uzavrit ticket (posle `closed: true`) a u uzavreneho Znovu
otevrit (`closed: false`). Text stavu tlacitko nemeni, ten se meni v poli
Stav kdykoliv a nezavisle. Server uz to umel (`POST /tickets/:id/status`
s `closed`).
- **Komentare.** Komentar se ukladal jako poznamka logu "autor: text" a
v detailu nebyl nikde videt, jen v logu. Ted je to vlastni druh radku
`kind: 'comment'` s polem `author` (sdileny typ `TicketTraceEntry`),
detail ma sekci Komentare (`TicketComments.tsx`) se seznamem a formularem
pod obsahem pozadavku, log ho ukazuje s autorem. Starsi komentare
ulozene jako poznamka v logu zustavaji. Test `tests/data/comments.test.ts`.
- **Zakaznik jen popisne.** Pryc je odznak "Zakaznik nedohledan", varovne
zbarveni firmy a ID v CRM, text "Firmu se nepodarilo dohledat v CRM,
ticket zustal bez resitele" a zvyrazneni "nedohledano" v tabulce a na
karte. Sekce rika, kdo pozadavek poslal, nic nehodnoti; ID v CRM se ukaze
jen kdyz existuje.
- **Dashboard misto Portal.** Slovo "portal" v uzivatelskych textech (tlacitko
v hlavicce webu, zalozka Prehled, kanal ticketu, nadpisy prihlaseni,
paticka, hlasky, popisky v Nastaveni) nahrazeno slovem "Dashboard";
zalozka Prehled v Lidech je "Souhrn". Kod, cesty (`/dashboard`), ID kanalu
`portal` a dokumentace zustavaji.
- **Audit.** "Odepreni" a "odepreno" nahrazeno "zamitnute pokusy" a
"zamitnuto".
## 2026-09-09 - Vykonnostni a zatezove testy, worker uz nespi pri plnem bazenu
Aplikace ma zvladat stovky uzivatelu a tisice kroku automatizaci, ale merit
+1 -1
View File
@@ -41,7 +41,7 @@ export interface ModuleDefinition {
* Nova zalozka = zaznam tady plus cesta na klientovi.
*/
export const moduleCatalog: ModuleDefinition[] = [
{ key: 'overview', label: 'Přehled', permissions: [], required: true },
{ key: 'overview', label: 'Dashboard', permissions: [], required: true },
{ key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] },
/*
* Helpdesk je pohled **zadavatele**, ne resitele: firma tu vidi pozadavky,
+3 -1
View File
@@ -398,7 +398,9 @@ export function addComment(
markResponded(ticket);
touch(ticket);
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
// Vlastni druh radku: detail z nej sklada sekci Komentare, log ho ukazuje
// s autorem. Driv byl komentar poznamka "autor: text" a v detailu nebyl videt.
appendTrace(id, [{ kind: 'comment', label: text, author, status: 'info' }]);
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
+3
View File
@@ -15,6 +15,8 @@ import { nextTraceId, ticketsById, traces } from './state.js';
export interface TraceInput {
kind: TraceKind;
label: string;
/** Autor komentare, jen u `kind: 'comment'`. */
author?: string;
status: TraceStatus;
serviceId?: string | null;
operationId?: string | null;
@@ -42,6 +44,7 @@ export function flattenTrace(
serviceId: input.serviceId ?? null,
operationId: input.operationId ?? null,
label: input.label,
...(input.author !== undefined ? { author: input.author } : {}),
status: input.status,
response: input.response ?? null,
durationMs: input.durationMs ?? null,
+1 -1
View File
@@ -544,7 +544,7 @@ builtinAction({
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
}),
apply: (ticket, input, tenantIds, req) =>
addComment(ticket.id, req.user?.name ?? 'Portál', input.text, tenantIds),
addComment(ticket.id, req.user?.name ?? 'Dashboard', input.text, tenantIds),
});
/**
+8 -1
View File
@@ -36,7 +36,12 @@ export interface TicketAssignee {
}
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
/**
* `comment` je komentar cloveka: ma autora a text, kresli se ve vlastni
* sekci detailu i v logu. `note` je poznamka systemu (prirazeni, zmena
* stavu), bez autora.
*/
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note' | 'comment';
/** Jeden radek v logu ticketu. Strom se sklada pres `parentId`. */
export interface TicketTraceEntry {
@@ -48,6 +53,8 @@ export interface TicketTraceEntry {
serviceId: string | null;
operationId: string | null;
label: string;
/** Kdo komentar napsal. Jen u `kind: 'comment'`, jinde chybi. */
author?: string;
status: TraceStatus;
/** Co sluzba vratila. Tohle je duvod, proc log existuje. */
response: string | null;
+39
View File
@@ -0,0 +1,39 @@
import { beforeAll, describe, expect, test } from 'vitest';
import { bootstrapData } from '../../src/data/bootstrap.js';
import { addComment, createTicket, getTicket } from '../../src/data/ticketStore.js';
const TENANT = 'tnt_automia';
describe('komentar k ticketu', () => {
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
});
test('je vlastni radek logu s autorem, ne poznamka "autor: text"', () => {
const ticket = createTicket({
tenantId: TENANT,
subject: 'Komentare',
channel: 'portal',
priority: 'normal',
});
addComment(ticket.id, 'Karel Vomacka', 'Zavolal jsem klientovi.', [TENANT]);
const detail = getTicket(ticket.id, [TENANT]);
const comment = detail?.trace.find((entry) => entry.kind === 'comment');
expect(comment).toBeDefined();
expect(comment?.author).toBe('Karel Vomacka');
expect(comment?.label).toBe('Zavolal jsem klientovi.');
});
test('cizi firma komentar nezapise', () => {
const ticket = createTicket({
tenantId: TENANT,
subject: 'Cizi',
channel: 'portal',
priority: 'normal',
});
expect(addComment(ticket.id, 'Nekdo', 'Ahoj', ['tnt_nordis'])).toBeUndefined();
expect(getTicket(ticket.id, [TENANT])?.trace.some((e) => e.kind === 'comment')).toBe(false);
});
});
+1 -1
View File
@@ -71,7 +71,7 @@ export default function App() {
<Suspense
fallback={
<div className="grid min-h-screen place-items-center bg-ink-950">
<Spinner label="Načítám portál..." />
<Spinner label="Načítám dashboard..." />
</div>
}
>
@@ -249,7 +249,7 @@ function AresTenantForm({
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-white">{item.name}</span>
<code className="text-xs text-white/60">IČ {item.ico}</code>
{taken && <Badge tone="neutral">už v portálu</Badge>}
{taken && <Badge tone="neutral">už v dashboardu</Badge>}
</div>
<div className="text-xs text-white/50">
{[item.legalForm, item.address].filter(Boolean).join(', ')}
@@ -264,7 +264,7 @@ function AresTenantForm({
{company && (
<div className="space-y-4 border-t border-white/10 pt-4">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Název v portálu" required>
<Field label="Název v dashboardu" required>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</Field>
<Field label="DIČ">
@@ -106,7 +106,7 @@ function TenantSwitch({ access }: { access: Access | null }) {
if (tenants.length <= 1) {
return (
<p className="truncate px-2 py-1 text-sm font-semibold text-white">
{current?.name ?? tenants[0]?.name ?? 'Klientský portál'}
{current?.name ?? tenants[0]?.name ?? 'Dashboard'}
</p>
);
}
@@ -311,7 +311,7 @@ function DashboardShell() {
*/}
<p className="truncate text-sm font-semibold text-white">
{access.data?.tenants.find((tenant) => tenant.id === activeTenant)?.name ??
'Klientský portál'}
'Dashboard'}
</p>
<p className="truncate text-xs text-white/40">{accessLabel(user, access.data)}</p>
</div>
+1 -1
View File
@@ -66,7 +66,7 @@ const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon
email: { label: 'E-mail', icon: Mail },
voice: { label: 'Hlasová linka', icon: PhoneCall },
form: { label: 'Formulář', icon: FileInput },
portal: { label: 'Portál', icon: LayoutDashboard },
portal: { label: 'Dashboard', icon: LayoutDashboard },
};
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
@@ -211,6 +211,37 @@ export function TicketAssignPanel({
))}
</datalist>
</Field>
{/*
Uzavreni meni jen priznak `closed`. Text stavu zustava, jaky je:
stav je volny retezec a meni se v poli nahore, kdykoliv a nezavisle.
Server chce stav v tele vzdy, proto se posila ten aktualni.
*/}
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-ink-600/50 pt-4">
{ticket.closed ? (
<Button
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void mutate('/status', { status: ticket.status, closed: false })}
>
Znovu otevřít
</Button>
) : (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/status', { status: ticket.status, closed: true })}
>
Uzavřít ticket
</Button>
)}
<span className="text-xs text-white/40">
{ticket.closed
? 'Ticket je uzavřený.'
: 'Uzavřený ticket zmizí z fronty a statistik práce.'}
</span>
</div>
</div>
);
}
+2 -8
View File
@@ -114,14 +114,8 @@ export function TicketCard({
{ticket.subject}
</p>
{!skip.has('customer') && ticket.customer && (
<p className="mt-1 truncate text-xs text-white/40">
{ticket.customer.id ? (
ticket.customer.company
) : (
<span className="text-warn-400">zákazník nedohledán</span>
)}
</p>
{!skip.has('customer') && ticket.customer && ticket.customer.company && (
<p className="mt-1 truncate text-xs text-white/40">{ticket.customer.company}</p>
)}
{ticket.externalId && (
@@ -0,0 +1,106 @@
import { MessageSquare, MessageSquarePlus } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/form/Textarea';
import { formatDateTime } from '@/lib/format';
import type { TicketTraceEntry } from '@/types/dashboard';
/** Nejkratsi komentar, ktery se odesle. Jedno pismeno je preklep, ne zprava. */
const MIN_COMMENT_LENGTH = 2;
/**
* Komentare k ticketu: seznam a formular na jednom miste.
*
* Komentar je radek logu s `kind: 'comment'` a autorem, takze zdrojem je
* tentyz log jako u behu automatizace - jedna casova osa, zadna druha
* kolekce. Tady se z ni ukazuji jen komentare, aby je uzivatel videl bez
* hledani v logu. Starsi komentare ulozene jako poznamka "autor: text"
* v logu zustavaji, tady se neukazou.
*/
export function TicketComments({
entries,
canComment,
busy,
onSubmit,
}: {
entries: TicketTraceEntry[];
canComment: boolean;
busy: boolean;
/** Odesle komentar. Vraci true pri uspechu, aby se pole vyprazdnilo. */
onSubmit: (text: string) => Promise<boolean>;
}) {
const [draft, setDraft] = useState('');
const comments = entries.filter((entry) => entry.kind === 'comment');
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = draft.trim();
if (text.length < MIN_COMMENT_LENGTH) return;
if (await onSubmit(text)) setDraft('');
}
return (
<section className="mt-5 border-t border-ink-600/50 pt-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<MessageSquare className="size-4 text-brand-300" />
Komentáře
{comments.length > 0 && (
<span className="text-xs font-normal text-white/40">{comments.length}</span>
)}
</h2>
{comments.length === 0 ? (
<p className="mt-3 text-sm text-white/40">Zatím bez komentáře.</p>
) : (
<ol className="mt-3 space-y-3">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-xl border border-ink-600/50 bg-ink-850/40 p-3.5"
>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-white">
{comment.author ?? 'Neznámý'}
</span>
<span className="text-xs text-white/35">{formatDateTime(comment.at)}</span>
</div>
<p className="mt-1.5 text-sm leading-relaxed whitespace-pre-wrap text-white/80">
{comment.label}
</p>
</li>
))}
</ol>
)}
{canComment && (
<form onSubmit={(event) => void submit(event)} className="mt-4">
<label htmlFor="ticket-comment" className="sr-only">
Nový komentář
</label>
<Textarea
id="ticket-comment"
rows={3}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Co jste zjistili nebo udělali..."
size="lg"
/>
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-white/35">
Komentář se zapíše i do logu, do stejné časové osy jako běh automatizace.
</p>
<Button
type="submit"
size="sm"
disabled={busy || draft.trim().length < MIN_COMMENT_LENGTH}
>
<MessageSquarePlus className="size-4" />
Přidat komentář
</Button>
</div>
</form>
)}
</section>
);
}
@@ -18,7 +18,7 @@ export function TicketEvents({ events }: { events: TicketEvent[] }) {
if (events.length === 0) {
return (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
Žádné příchozí události. Ticket vznikl v portálu nebo ručně.
Žádné příchozí události. Ticket vznikl v dashboardu nebo ručně.
</p>
);
}
+1 -5
View File
@@ -98,11 +98,7 @@ export function TicketTable({
{!skip.has('customer') && (
<td className="px-3 py-3">
{ticket.customer.id ? (
<span className="text-white/55">{ticket.customer.company}</span>
) : (
<span className="text-warn-400">nedohledáno</span>
)}
<span className="text-white/55">{ticket.customer.company || '-'}</span>
{ticket.customer.contact && (
<span className="block text-xs text-white/30">{ticket.customer.contact}</span>
)}
+21 -3
View File
@@ -1,4 +1,12 @@
import { AlertCircle, CheckCircle2, GitBranch, Info, MinusCircle, Zap } from 'lucide-react';
import {
AlertCircle,
CheckCircle2,
GitBranch,
Info,
MessageSquare,
MinusCircle,
Zap,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useMemo } from 'react';
import { cn } from '@/lib/cn';
@@ -85,7 +93,17 @@ export function TicketTrace({
function TraceRow({ node, services }: { node: TraceNode; services: Service[] }) {
const meta = statusMeta[node.status];
const Icon = node.kind === 'condition' ? GitBranch : node.kind === 'trigger' ? Zap : meta.icon;
const Icon =
node.kind === 'condition'
? GitBranch
: node.kind === 'trigger'
? Zap
: node.kind === 'comment'
? MessageSquare
: meta.icon;
// Komentar nese autora zvlast; v logu se ukaze jako "Jmeno: text".
const label =
node.kind === 'comment' && node.author ? `${node.author}: ${node.label}` : node.label;
const service = describeService(node, services);
return (
@@ -105,7 +123,7 @@ function TraceRow({ node, services }: { node: TraceNode; services: Service[] })
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm font-medium text-white/85">{node.label}</p>
<p className="text-sm font-medium text-white/85">{label}</p>
{service && <span className="font-mono text-xs text-brand-300/80">{service}</span>}
</div>
@@ -31,7 +31,7 @@ export function AuditView() {
<div>
<h2 className="font-semibold text-white">Audit</h2>
<p className="mt-0.5 text-sm text-white/45">
Kdo co udělal. Zapisují se i odepření, jinak by pokusy o cizí firmu nikde nezůstaly.
Kdo co udělal. Zapisují se i zamítnuté pokusy, jinak by pokus o cizí firmu nikde nezůstal.
</p>
</div>
@@ -42,7 +42,7 @@ export function AuditView() {
onChange={(event) => setOnlyDenied(event.target.checked)}
className="size-4"
/>
Jen odepření
Jen zamítnuté
</label>
<div className="glass overflow-x-auto rounded-card">
@@ -72,7 +72,7 @@ export function AuditView() {
<Td className="text-white/50">{entry.target ?? ''}</Td>
<Td>
{entry.result === 'denied' ? (
<Badge tone="danger">odepřeno</Badge>
<Badge tone="danger">zamítnuto</Badge>
) : (
<Badge tone="ok">ok</Badge>
)}
@@ -44,7 +44,7 @@ export function TenantsAdmin() {
return (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
description="Firmy, které dashboard používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
refetchOn={['tenant.created', 'tenant.updated', 'tenant.deleted']}
toBody={(values) => ({
@@ -131,7 +131,7 @@ export function TenantsAdmin() {
pri zapnuti odebere priznak ostatnim, tady se nic nehlida.
*/
name: 'portalOperator',
label: 'Provozovatel portálu',
label: 'Provozovatel (kontakty na webu, poptávky)',
kind: 'checkbox',
hint: 'Sem chodí poptávky z webu jako tickety. Může být jen jedna firma.',
},
+1 -1
View File
@@ -27,7 +27,7 @@ export function usePageMeta({
const fullTitle = !title
? `${name} - ${brand.claim}`
: area === 'portal'
? `${title} - portál ${name}`
? `${title} - ${name}`
: `${title} - ${name}`;
useEffect(() => {
+11 -11
View File
@@ -15,7 +15,7 @@ export const cs = {
'nav.contact': 'Kontakt',
'nav.signIn': 'Přihlášení',
'nav.getStarted': 'Začít zdarma',
'nav.dashboard': 'Portál',
'nav.dashboard': 'Dashboard',
'nav.openMenu': 'Otevřít menu',
'nav.closeMenu': 'Zavřít menu',
'nav.language': 'Jazyk',
@@ -52,7 +52,7 @@ export const cs = {
'panel.col.assignee': 'Řeší',
'panel.col.updated': 'Aktualizace',
'panel.role': 'Vedoucí týmu',
'panel.nav.overview': 'Přehled',
'panel.nav.overview': 'Dashboard',
'panel.nav.tickets': 'Tickety',
'panel.nav.helpdesk': 'Helpdesk',
'panel.nav.people': 'Lidé',
@@ -70,7 +70,7 @@ export const cs = {
'panel.row3.channel': 'WhatsApp',
'panel.row3.status': 'Čeká na zákazníka',
'panel.row4.subject': 'Chyba při zaúčtování platby',
'panel.row4.channel': 'Portál',
'panel.row4.channel': 'Dashboard',
'panel.row4.status': 'Vyřešeno',
'panel.queue': 've frontě',
'panel.ago2min': 'před 2 min',
@@ -105,7 +105,7 @@ export const cs = {
'footer.link.dashboards': 'Dashboardy',
'footer.link.about': 'O nás',
'footer.link.contact': 'Kontakt',
'footer.link.portal': 'Klientský portál',
'footer.link.portal': 'Dashboard',
'footer.prototype': 'Prototyp webu - obsah a reference jsou ukázkové (mockup).',
// ----------------------------------------------------------------- claim
@@ -259,7 +259,7 @@ export const cs = {
'Dnes je náš přístup jednoduchý: dodáme malý funkční celek, změříme, co ušetřil, a teprve potom stavíme dál.',
'about.milestone2018': 'Vznik firmy - první integrace e-shopů s účetnictvím.',
'about.milestone2020': 'První hlasová linka v produkci, tým se rozrůstá na čtyři lidi.',
'about.milestone2023': 'Spouštíme vlastní klientský portál s tickety a incidenty.',
'about.milestone2023': 'Spouštíme vlastní dashboard pro klienty s tickety a incidenty.',
'about.milestone2025': 'Provozujeme 120+ automatizací s dostupností 99,98 %.',
'about.valuesEyebrow': 'Jak pracujeme',
'about.valuesTitle': 'Čtyři věci, na kterých si trváme',
@@ -331,8 +331,8 @@ export const cs = {
'contact.billingDic': 'DIČ',
'contact.clientTitle': 'Jste náš klient?',
'contact.clientText':
'Požadavky a incidenty zadávejte přímo v klientském portálu - mají tam SLA a vidíte jejich stav.',
'contact.clientLink': 'Přejít do portálu',
'Požadavky a incidenty zadávejte přímo v dashboardu - mají tam SLA a vidíte jejich stav.',
'contact.clientLink': 'Přejít do dashboardu',
// ------------------------------------------------------------------- 404
'notFound.title': 'Stránka nenalezena',
@@ -343,9 +343,9 @@ export const cs = {
// ----------------------------------------------------------- prihlaseni
'login.title': 'Přihlášení',
'login.meta': 'Přihlášení do klientského portálu {brand}.',
'login.meta': 'Přihlášení do dashboardu {brand}.',
'login.back': 'Zpět na web',
'login.heading': 'Klientský portál',
'login.heading': 'Dashboard',
'login.lead': 'Přihlaste se a uvidíte stav automatizací, ticketů i incidentů.',
'login.email': 'E-mail',
'login.emailPlaceholder': 'vas@email.cz',
@@ -362,7 +362,7 @@ export const cs = {
'login.demo.agent': 'řešitel jedné firmy',
'login.noAccount': 'Nemáte účet?',
'login.contactUs': 'Ozvěte se nám',
'login.noAccountTail': '- portál zřizujeme klientům {brand}.',
'login.noAccountTail': '- dashboard zřizujeme klientům {brand}.',
// ------------------------------------------------- stavy dat a pojistka
'state.loading': 'Načítám data...',
@@ -371,7 +371,7 @@ export const cs = {
'state.empty': 'Žádná data k zobrazení.',
'error.title': 'Tuhle část se nepodařilo vykreslit',
'error.text':
'Zbytek portálu funguje dál. Zkuste to znovu, a když to bude přetrvávat, pošlete nám prosím tuhle hlášku.',
'Zbytek dashboardu funguje dál. Zkuste to znovu, a když to bude přetrvávat, pošlete nám prosím tuhle hlášku.',
} as const;
export type MessageKey = keyof typeof cs;
+1 -1
View File
@@ -227,4 +227,4 @@
*
* Nekonecne animace jsou to jedine, co na klidne strance porad zamestnava
* skladac - a prave ty jsou tady zbytne.
*/
*/
+1 -1
View File
@@ -102,7 +102,7 @@ export default function Invite() {
) : done ? (
<p className="flex items-center gap-2 text-ok-400">
<CheckCircle2 className="size-5" />
Hotovo, přesměrovávám do portálu.
Hotovo, přesměrovávám do dashboardu.
</p>
) : (
<>
+1 -1
View File
@@ -59,7 +59,7 @@ const COMPUTED_BUILTINS = new Set(['panel.agents', 'list.myTickets', 'list.unass
const MIN_EXTERNAL_REFRESH_MS = 5_000;
export default function Overview() {
usePageMeta({ title: 'Přehled', area: 'portal' });
usePageMeta({ title: 'Dashboard', area: 'portal' });
const { user } = useAuth();
+3 -3
View File
@@ -51,7 +51,7 @@ export default function People() {
* to bylo schovane v nastaveni, nikdo to nenasel.
*/
const tabs = [
{ key: 'prehled', label: 'Přehled', show: true },
{ key: 'prehled', label: 'Souhrn', show: true },
{ key: 'resitele', label: 'Řešitelé', show: can('people.manage') },
{ key: 'skupiny', label: 'Sekce', show: can('group.manage') },
{ key: 'role', label: 'Role a práva', show: can('role.manage') },
@@ -280,7 +280,7 @@ function PeopleAdmin({ onChanged }: { onChanged: () => void }) {
return (
<EntityAdmin
title="Řešitelé"
description="Lidé ve firmě. Každý člen má účet do portálu a může mít u sebe tickety. Smazání jen odebere členství ve firmě, účet zůstává."
description="Lidé ve firmě. Každý člen má účet do dashboardu a může mít u sebe tickety. Smazání jen odebere členství ve firmě, účet zůstává."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
refetchOn={['person.created', 'person.updated', 'person.deleted']}
@@ -324,7 +324,7 @@ function PeopleAdmin({ onChanged }: { onChanged: () => void }) {
initial: agentRoleId ? [agentRoleId] : [],
// Bez roli v zaznamu by prazdny vyber pri uprave role tise smazal.
visible: (person) => person === null || Array.isArray(person.roleIds),
hint: 'Co smí v portálu dělat. Nový člověk je agent.',
hint: 'Co smí v dashboardu dělat. Nový člověk je agent.',
},
{
name: 'role',
+2 -2
View File
@@ -214,7 +214,7 @@ function AccountSwitch() {
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Přepnutí na jiný účet</h3>
<p className="mt-1 text-sm text-white/45">
Uvidíte portál jeho očima, včetně záložek a práv. Zaznamená se to do auditu.
Uvidíte dashboard jeho očima, včetně záložek a práv. Zaznamená se to do auditu.
</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
@@ -277,7 +277,7 @@ function UsersAdmin() {
return (
<EntityAdmin
title="Uživatelé"
description="Kdo se přihlásí do portálu. Členství určuje, do kterých firem vidí a s jakými rolemi."
description="Kdo se přihlásí do dashboardu. Členství určuje, do kterých firem vidí a s jakými rolemi."
endpoint="/api/dashboard/settings/users"
listEndpoint="/api/dashboard/settings/users-overview"
columns={[
+13 -81
View File
@@ -1,14 +1,5 @@
import {
AlertCircle,
ArrowLeft,
Building2,
FileText,
Inbox,
MessageSquarePlus,
ScrollText,
} from 'lucide-react';
import { AlertCircle, ArrowLeft, Building2, FileText, Inbox, ScrollText } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import {
@@ -19,12 +10,11 @@ import {
import { TicketActions } from '@/components/dashboard/TicketActions';
import { TicketAssignPanel } from '@/components/dashboard/TicketAssignPanel';
import { TicketAttachments } from '@/components/dashboard/TicketAttachments';
import { TicketComments } from '@/components/dashboard/TicketComments';
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 { Input } from '@/components/ui/form/Input';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useAccess, useCollection } from '@/lib/collections';
@@ -34,9 +24,6 @@ import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import type { TicketDetail as Detail } from '@/types/dashboard';
/** Nejkratsi komentar, ktery se odesle. Jedno pismeno je preklep, ne zprava. */
const MIN_COMMENT_LENGTH = 2;
export default function TicketDetail() {
const { id = '' } = useParams();
@@ -58,8 +45,6 @@ export default function TicketDetail() {
/** Nahravat a mazat prilohy smi ten, kdo smi komentovat. */
const canAttach = access.data?.permissions.includes('ticket.comment') === true;
const [comment, setComment] = useState('');
/**
* Ktery list detailu je navrchu.
*
@@ -102,14 +87,6 @@ export default function TicketDetail() {
setActionError(null);
}, [ticket.data?.updatedAt, setActionError]);
async function submitComment(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = comment.trim();
if (text.length < MIN_COMMENT_LENGTH) return;
// Pri chybe text nechame v poli, aby ho uzivatel nemusel psat znovu.
if (await mutate('/comment', { text })) setComment('');
}
return (
<div className="space-y-6">
<Link
@@ -144,12 +121,6 @@ export default function TicketDetail() {
{ticket.data.reopenCount > 0 && (
<Badge tone="warn">vráceno {ticket.data.reopenCount}x</Badge>
)}
{ticket.data.customer.id === null && (
<Badge tone="warn">
<AlertCircle className="size-3.5" />
Zákazník nedohledán
</Badge>
)}
</div>
<h1 className="mt-2 text-2xl font-bold text-white">{ticket.data.subject}</h1>
</header>
@@ -209,39 +180,12 @@ export default function TicketDetail() {
<TicketBody body={ticket.data.body} sourceRef={ticket.data.sourceRef} />
</div>
<form
onSubmit={(event) => void submitComment(event)}
className="mt-5 border-t border-ink-600/50 pt-5"
>
<label
htmlFor="ticket-comment"
className="text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Přidat komentář
</label>
<div className="mt-2 flex flex-wrap gap-2">
<Input
id="ticket-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Co jste zjistili nebo udělali..."
size="lg"
fullWidth={false}
className="min-w-0 flex-1"
/>
<Button
type="submit"
size="sm"
disabled={busy || comment.trim().length < MIN_COMMENT_LENGTH}
>
<MessageSquarePlus className="size-4" />
Zapsat
</Button>
</div>
<p className="mt-2 text-xs text-white/35">
Komentář se zapíše do stejné časové osy jako běh automatizace.
</p>
</form>
<TicketComments
entries={ticket.data.trace}
canComment={canAttach}
busy={busy}
onSubmit={(text) => mutate('/comment', { text })}
/>
</>
)}
@@ -303,27 +247,15 @@ export default function TicketDetail() {
<Building2 className="size-4 text-brand-300" />
Zákazník
</h2>
{/* Jen popis, kdo pozadavek poslal. Zadne hodnoceni ani varovani. */}
<dl className="mt-3 space-y-2.5 text-sm">
<Row
label="Firma"
value={ticket.data.customer.company}
warn={ticket.data.customer.id === null}
/>
<Row label="Firma" value={ticket.data.customer.company} />
<Row label="Kontakt" value={ticket.data.customer.contact} />
<Row label="Odpověď na" value={ticket.data.customer.reply} />
<Row
label="ID v CRM"
value={ticket.data.customer.id ?? 'nedohledáno'}
warn={ticket.data.customer.id === null}
/>
{ticket.data.customer.id && (
<Row label="ID v CRM" value={ticket.data.customer.id} />
)}
</dl>
{ticket.data.customer.id === null && (
<p className="mt-3 rounded-lg border border-warn-400/30 bg-warn-500/8 px-3 py-2.5 text-xs leading-relaxed text-white/60">
Firmu se nepodařilo dohledat v CRM. Ticket proto zůstal bez řešitele, není
podle čeho určit garanta.
</p>
)}
</div>
<div className="glass rounded-card p-5">
+1 -1
View File
@@ -42,7 +42,7 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'email', label: 'E-mail' },
{ value: 'voice', label: 'Hlasová linka' },
{ value: 'form', label: 'Formulář' },
{ value: 'portal', label: 'Portál' },
{ value: 'portal', label: 'Dashboard' },
];
type Tab = 'moje' | 'fronta' | 'vsechny';