diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index 796b418..81050ba 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -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` diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 511cb47..1acdb48 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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 diff --git a/src/data/tenantFeatures.ts b/src/data/tenantFeatures.ts index e3f2631..8bcaedd 100644 --- a/src/data/tenantFeatures.ts +++ b/src/data/tenantFeatures.ts @@ -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, diff --git a/src/data/tickets/store.ts b/src/data/tickets/store.ts index a5f6611..bb9bdc2 100644 --- a/src/data/tickets/store.ts +++ b/src/data/tickets/store.ts @@ -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); diff --git a/src/data/tickets/trace.ts b/src/data/tickets/trace.ts index f14073e..9b51a01 100644 --- a/src/data/tickets/trace.ts +++ b/src/data/tickets/trace.ts @@ -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, diff --git a/src/routes/ticketActions.ts b/src/routes/ticketActions.ts index 5a91fb7..776e0f1 100644 --- a/src/routes/ticketActions.ts +++ b/src/routes/ticketActions.ts @@ -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), }); /** diff --git a/src/shared/tickets.ts b/src/shared/tickets.ts index adefda6..2becbac 100644 --- a/src/shared/tickets.ts +++ b/src/shared/tickets.ts @@ -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; diff --git a/tests/data/comments.test.ts b/tests/data/comments.test.ts new file mode 100644 index 0000000..e112c9d --- /dev/null +++ b/tests/data/comments.test.ts @@ -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); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 87c909a..39b11d3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -71,7 +71,7 @@ export default function App() { - + } > diff --git a/web/src/components/dashboard/AresTenantDialog.tsx b/web/src/components/dashboard/AresTenantDialog.tsx index 7b4df99..606e925 100644 --- a/web/src/components/dashboard/AresTenantDialog.tsx +++ b/web/src/components/dashboard/AresTenantDialog.tsx @@ -249,7 +249,7 @@ function AresTenantForm({
{item.name} IČ {item.ico} - {taken && už v portálu} + {taken && už v dashboardu}
{[item.legalForm, item.address].filter(Boolean).join(', ')} @@ -264,7 +264,7 @@ function AresTenantForm({ {company && (
- + setName(event.target.value)} /> diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index b8612a1..add0b5d 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -106,7 +106,7 @@ function TenantSwitch({ access }: { access: Access | null }) { if (tenants.length <= 1) { return (

- {current?.name ?? tenants[0]?.name ?? 'Klientský portál'} + {current?.name ?? tenants[0]?.name ?? 'Dashboard'}

); } @@ -311,7 +311,7 @@ function DashboardShell() { */}

{access.data?.tenants.find((tenant) => tenant.id === activeTenant)?.name ?? - 'Klientský portál'} + 'Dashboard'}

{accessLabel(user, access.data)}

diff --git a/web/src/components/dashboard/StatusBadge.tsx b/web/src/components/dashboard/StatusBadge.tsx index 9cb2395..7890d30 100644 --- a/web/src/components/dashboard/StatusBadge.tsx +++ b/web/src/components/dashboard/StatusBadge.tsx @@ -66,7 +66,7 @@ const ticketChannelMap: Record = { diff --git a/web/src/components/dashboard/TicketAssignPanel.tsx b/web/src/components/dashboard/TicketAssignPanel.tsx index 57d39f8..84a2e9a 100644 --- a/web/src/components/dashboard/TicketAssignPanel.tsx +++ b/web/src/components/dashboard/TicketAssignPanel.tsx @@ -211,6 +211,37 @@ export function TicketAssignPanel({ ))} + + {/* + 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. + */} +
+ {ticket.closed ? ( + + ) : ( + + )} + + {ticket.closed + ? 'Ticket je uzavřený.' + : 'Uzavřený ticket zmizí z fronty a statistik práce.'} + +
); } diff --git a/web/src/components/dashboard/TicketCard.tsx b/web/src/components/dashboard/TicketCard.tsx index a08a604..1bf4dcf 100644 --- a/web/src/components/dashboard/TicketCard.tsx +++ b/web/src/components/dashboard/TicketCard.tsx @@ -114,14 +114,8 @@ export function TicketCard({ {ticket.subject}

- {!skip.has('customer') && ticket.customer && ( -

- {ticket.customer.id ? ( - ticket.customer.company - ) : ( - zákazník nedohledán - )} -

+ {!skip.has('customer') && ticket.customer && ticket.customer.company && ( +

{ticket.customer.company}

)} {ticket.externalId && ( diff --git a/web/src/components/dashboard/TicketComments.tsx b/web/src/components/dashboard/TicketComments.tsx new file mode 100644 index 0000000..2b11cd6 --- /dev/null +++ b/web/src/components/dashboard/TicketComments.tsx @@ -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; +}) { + const [draft, setDraft] = useState(''); + const comments = entries.filter((entry) => entry.kind === 'comment'); + + async function submit(event: FormEvent) { + event.preventDefault(); + const text = draft.trim(); + if (text.length < MIN_COMMENT_LENGTH) return; + if (await onSubmit(text)) setDraft(''); + } + + return ( +
+

+ + Komentáře + {comments.length > 0 && ( + {comments.length} + )} +

+ + {comments.length === 0 ? ( +

Zatím bez komentáře.

+ ) : ( +
    + {comments.map((comment) => ( +
  1. +
    + + {comment.author ?? 'Neznámý'} + + {formatDateTime(comment.at)} +
    +

    + {comment.label} +

    +
  2. + ))} +
+ )} + + {canComment && ( +
void submit(event)} className="mt-4"> + +