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:
co-authored by
Claude Fable 5.1
parent
5d41600578
commit
0ae9dab1d4
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -227,4 +227,4 @@
|
||||
*
|
||||
* Nekonecne animace jsou to jedine, co na klidne strance porad zamestnava
|
||||
* skladac - a prave ty jsou tady zbytne.
|
||||
*/
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user