Nastaveni prezije nasazeni, ukazkova data uz se nevraci

Bez databaze lezi data uvnitr containeru, takze redeploy je smaze a seed je
nasype znovu. Dokud nebude Postgres, resi se to trema vecmi:

Ukazkova data jen se SEED_DEMO=1. Automatizace, tickety a incidenty se uz po
kazdem nasazeni nevraci. Konfigurace se nasypava dal, bez ni je portal
nepouzitelny.

Token webhooku z WEBHOOK_TOKEN_TEST. Driv se pri kazdem nasazeni vygeneroval
novy, takze odesilatel musel prepisovat adresu ve svem kodu. Ted je token
v promenne aplikace: neni v gitu a adresa se nemeni.

Automatizace, ktera na instanci opravdu bezi, je v seedu. Je to provizorium,
ne cil - az data prezijou nasazeni, patri zpatky do dat.

Dal:

- `ticket/upsert` umi vsechna pole ticketu: zakaznik, kanal, odkaz na zdroj,
  priorita, stitky, resitel, skupina a vlastni pole typu jako JSON. Zakaznik
  a kanal se vyplnuji jen pri zalozeni, aby pozdejsi udalost s prazdnym
  jmenem neprepsala, co uz tam je.
- Kroky ve strome jdou sbalit, vychozi je sbaleno. Sbaleny krok ukazuje, co
  ma vyplneno, ne popis operace.
- Seskupovani widgetu podle faze a dva nove widgety: tickety podle stavu
  a podle faze za tento mesic, obojí s proklikem na vyfiltrovany seznam.
- Faze se ukazuje jako stav. Driv byl videt jen nas ctyrprvkovy ciselnik,
  coz u ticketu z cizi aplikace nedava smysl. Zivotni cyklus zustava vedle
  jako drobny text, protoze se z nej pocitaji statistiky.

Overeno 18 kontrolami proti bezicimu serveru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 17:21:32 +02:00
co-authored by Claude Opus 5
parent 6abac82d5e
commit ef3080d3f6
17 changed files with 1170 additions and 757 deletions
+31 -1
View File
@@ -66,9 +66,39 @@ const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone
resolved: { label: 'Vyřešeno', tone: 'ok' },
};
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
/**
* Stav ticketu.
*
* Kdyz ma ticket **fazi** z workflow sveho typu, ukaze se **ona**, protoze to
* je hodnota, kterou tam dala automatizace nebo clovek a kterou lidi ve firme
* pouzivaji. Nas zivotni cyklus (novy, v reseni, ceka, vyreseno) zustava
* vedle jako mensi znacka - pocitaji se z nej statistiky a fronta, takze
* schovat ho uplne by znamenalo, ze nikdo nepozna, proc je ticket ve fronte.
*/
export function TicketStatusBadge({
status,
stage,
}: {
status: TicketStatus;
stage?: string | null;
}) {
const { label, tone } = ticketStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'waiting' ? Clock : CircleDot;
if (stage) {
return (
<span className="inline-flex flex-wrap items-center gap-1.5">
<Badge tone={tone}>
<Icon className="size-3.5" />
{stage}
</Badge>
<span className="text-xs text-white/30" title="Stav v životním cyklu, podle něj se počítají statistiky">
{label}
</span>
</span>
);
}
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
+2 -2
View File
@@ -101,7 +101,7 @@ export function TicketTable({
)}
<td className="px-3 py-3">
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</td>
<td className="px-3 py-3">
<TicketPriorityBadge priority={ticket.priority} />
@@ -143,7 +143,7 @@ export function TicketTable({
<Link to={`/dashboard/tickety/${ticket.id}`} className="block">
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</div>
<p className="mt-1 text-sm text-white/85">{ticket.subject}</p>
@@ -7,6 +7,7 @@ import {
Trash2,
Zap,
} from 'lucide-react';
import { useState } from 'react';
import { StepInputs } from '@/components/dashboard/flow/StepInputs';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
@@ -361,6 +362,7 @@ function ActionCard({
onMove: (offset: number) => void;
onUpdateInputs: (inputs: Record<string, string>) => void;
}) {
const [open, setOpen] = useState(false);
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
if (!resolved) {
@@ -376,20 +378,76 @@ function ActionCard({
const Icon = serviceIcon(resolved.connector.icon);
const inputs = resolved.operation.inputs;
/*
* Sbalene kroky.
*
* Strom o peti krocich s vyplnenymi poli je pres celou obrazovku a nejde
* v nem najit, co kde je. Sbaleny krok ukazuje jen to podstatne: co dela
* a co ma vyplneno. Vychozi je **sbaleno**, protoze vetsinou clovek strom
* cte, ne prenastavuje.
*/
const filled = Object.entries(step.inputs ?? {}).filter(([, value]) => value.trim() !== '');
return (
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 @xs:size-10">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
title={open ? 'Sbalit krok' : 'Rozbalit krok'}
className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 transition-colors hover:bg-ink-700 @xs:size-10"
>
<Icon className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold break-words text-white">{resolved.operation.name}</p>
{/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */}
<p className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</p>
</div>
</button>
<button
type="button"
onClick={() => setOpen(!open)}
className="min-w-0 flex-1 text-left"
>
<span className="block truncate text-xs text-white/40">{resolved.connector.name}</span>
<span className="mt-0.5 flex flex-wrap items-center gap-2 font-semibold break-words text-white">
{resolved.operation.name}
<ChevronDown
className={cn(
'size-4 shrink-0 text-white/30 transition-transform',
open && 'rotate-180',
)}
/>
</span>
{open ? (
/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */
<span className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</span>
) : (
/*
* Sbalene: co ma krok vyplneno. Tohle clovek hleda, kdyz cte strom -
* ne popis operace, ktery zna.
*/
<span className="mt-1 flex flex-wrap gap-1.5">
{filled.length === 0 ? (
<span className="text-xs text-white/30">nic nevyplněno</span>
) : (
filled.slice(0, 4).map(([key, value]) => (
<span
key={key}
className="max-w-56 truncate rounded bg-ink-950/60 px-1.5 py-0.5 font-mono text-xs text-white/50"
title={`${key}: ${value}`}
>
{key}: {value}
</span>
))
)}
{filled.length > 4 && (
<span className="text-xs text-white/30">a {filled.length - 4} dalších</span>
)}
</span>
)}
</button>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
@@ -398,23 +456,24 @@ function ActionCard({
/>
</div>
{inputs && inputs.length > 0 ? (
<StepInputs
fields={inputs}
values={step.inputs ?? {}}
available={available}
onChange={onUpdateInputs}
/>
) : (
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
Tuhle akci zatím nejde nastavit.
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
)}
{open &&
(inputs && inputs.length > 0 ? (
<StepInputs
fields={inputs}
values={step.inputs ?? {}}
available={available}
onChange={onUpdateInputs}
/>
) : (
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
Tuhle akci zatím nejde nastavit.
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
))}
</div>
);
}
+1 -1
View File
@@ -202,7 +202,7 @@ function TicketCards({
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</div>
<p className="mt-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
+1 -1
View File
@@ -117,7 +117,7 @@ export default function TicketDetail() {
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-white/35">{ticket.data.id}</span>
<TicketChannelBadge channel={ticket.data.channel} />
<TicketStatusBadge status={ticket.data.status} />
<TicketStatusBadge status={ticket.data.status} stage={ticket.data.stage} />
<TicketPriorityBadge priority={ticket.data.priority} />
{ticket.data.externalId && (
<Badge tone="neutral">
+9 -1
View File
@@ -63,6 +63,7 @@ export default function Tickets() {
channel: params.get('channel') as TicketChannel | null,
typeId: params.get('typeId'),
tag: params.get('tag'),
stage: params.get('stage'),
groupId: params.get('groupId'),
}).current;
@@ -75,6 +76,7 @@ export default function Tickets() {
/** Filtry, ktere se daji nastavit jen odkazem. V UI k nim tlacitko neni. */
const [typeId, setTypeId] = useState<string | null>(initial.typeId);
const [tag, setTag] = useState<string | null>(initial.tag);
const [stage, setStage] = useState<string | null>(initial.stage);
const [groupId, setGroupId] = useState<string | null>(initial.groupId);
const [query, setQuery] = useState('');
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
@@ -90,6 +92,7 @@ export default function Tickets() {
if (assignee) params.set('assignee', assignee);
if (typeId) params.set('typeId', typeId);
if (tag) params.set('tag', tag);
if (stage) params.set('stage', stage);
if (groupId) params.set('groupId', groupId);
if (status) params.set('status', status);
if (channel) params.set('channel', channel);
@@ -239,6 +242,11 @@ export default function Tickets() {
tag: {tag === 'none' ? 'bez tagu' : tag} x
</Chip>
)}
{stage && (
<Chip active onClick={() => setStage(null)} title="Zrušit filtr na fázi">
fáze: {stage === 'none' ? 'bez fáze' : stage} x
</Chip>
)}
{groupId && (
<Chip active onClick={() => setGroupId(null)} title="Zrušit filtr na skupinu">
skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x
@@ -293,7 +301,7 @@ export default function Tickets() {
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</div>
<p className="mt-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">