Obsah ticketu, nastaveni kroku a vystupy kroku

Ticket dostal telo (body) a odkaz na zdrojovou zpravu. Predmet je shrnuti,
telo je cely text pozadavku.

Akce maji nastavitelna pole (inputs) se sablonami {{parametr}}. Zatim ticket,
kanaly, CRM a AI, ostatni maji jen napovedu.

Krok vidi parametry spoustece plus vystupy kroku pred nim, takze jde vlozit
predvalidaci a vetvit se podle jejiho vysledku. Vetev podminky nepridava nic
do sekvence za podminkou.

Nove konektory Facebook Messenger a Instagram, nova akce RAYNET Dohledat firmu.

Ctyri vzorove automatizace v rozdeleni jedna na kanal pro prijem
a jedna spolecna pro smerovani na resitele.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-03 12:29:26 +02:00
parent 52b190bfbc
commit dd021b5f69
24 changed files with 1651 additions and 80 deletions
@@ -28,6 +28,8 @@ const priorities = [
/** Kanal urcuje nejen text, ale i to, jak bude vypadat log ticketu. */
const channels = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'facebook', label: 'Facebook Messenger' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'email', label: 'E-mail' },
{ value: 'voice', label: 'Hlasová linka' },
{ value: 'form', label: 'Webový formulář' },
@@ -3,7 +3,9 @@ import {
CheckCircle2,
CircleDot,
Clock,
Facebook,
FileInput,
Instagram,
LayoutDashboard,
Mail,
MessageCircle,
@@ -43,6 +45,8 @@ const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone
/** Kanal nese ikonu, ne barvu - neni to stav, nema co kricet. */
const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon }> = {
whatsapp: { label: 'WhatsApp', icon: MessageCircle },
facebook: { label: 'Facebook', icon: Facebook },
instagram: { label: 'Instagram', icon: Instagram },
email: { label: 'E-mail', icon: Mail },
voice: { label: 'Hlasová linka', icon: PhoneCall },
form: { label: 'Formulář', icon: FileInput },
@@ -1,10 +1,11 @@
import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react';
import type { ReactNode } from 'react';
import { StepInputs } from '@/components/dashboard/flow/StepInputs';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import {
collectScopes,
defaultOperatorFor,
describeCondition,
isUnaryOperator,
@@ -35,6 +36,7 @@ interface CanvasCallbacks {
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
) => void;
onUpdateInputs: (stepId: string, inputs: Record<string, string>) => void;
onMoveStep: (stepId: string, offset: number) => void;
}
@@ -73,7 +75,8 @@ export function FlowCanvas({
steps={flow.steps}
path={[]}
connectors={connectors}
fields={flow.trigger.fields}
scopes={collectScopes(flow, connectors)}
fallbackFields={flow.trigger.fields}
{...callbacks}
/>
)}
@@ -181,17 +184,21 @@ function StepSequence({
steps,
path,
connectors,
fields,
scopes,
fallbackFields,
compact = false,
...callbacks
}: CanvasCallbacks & {
steps: FlowStep[];
path: FlowPath;
connectors: Connector[];
fields: TriggerField[];
/** Co je videt v kterem kroku. Klic je ID kroku. */
scopes: Map<string, TriggerField[]>;
/** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */
fallbackFields: TriggerField[];
compact?: boolean;
}) {
const { onAddStep, onRemoveStep, onMoveStep } = callbacks;
const { onAddStep, onRemoveStep, onMoveStep, onUpdateInputs } = callbacks;
return (
<div className="flex flex-col items-stretch">
@@ -203,17 +210,21 @@ function StepSequence({
<ActionCard
step={step}
connectors={connectors}
available={scopes.get(step.id) ?? fallbackFields}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
onRemove={() => onRemoveStep(step.id)}
onMove={(offset) => onMoveStep(step.id, offset)}
onUpdateInputs={(inputs) => onUpdateInputs(step.id, inputs)}
/>
) : (
<ConditionCard
step={step}
path={path}
connectors={connectors}
fields={fields}
scopes={scopes}
fallbackFields={fallbackFields}
available={scopes.get(step.id) ?? fallbackFields}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
{...callbacks}
@@ -260,17 +271,22 @@ function AddStepButton({
function ActionCard({
step,
connectors,
available,
canMoveUp,
canMoveDown,
onRemove,
onMove,
onUpdateInputs,
}: {
step: Extract<FlowStep, { kind: 'action' }>;
connectors: Connector[];
/** Parametry viditelne prave v tomto miste stromu. */
available: TriggerField[];
canMoveUp: boolean;
canMoveDown: boolean;
onRemove: () => void;
onMove: (offset: number) => void;
onUpdateInputs: (inputs: Record<string, string>) => void;
}) {
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
@@ -285,24 +301,45 @@ function ActionCard({
}
const Icon = connectorIcon(resolved.connector.icon);
const inputs = resolved.operation.inputs;
return (
<StepShell>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
<div className="rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={onMove}
onRemove={onRemove}
/>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={onMove}
onRemove={onRemove}
/>
</StepShell>
{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 && (
<> půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
)}
</div>
);
}
@@ -310,7 +347,9 @@ function ConditionCard({
step,
path,
connectors,
fields,
scopes,
fallbackFields,
available: fields,
canMoveUp,
canMoveDown,
...callbacks
@@ -318,7 +357,9 @@ function ConditionCard({
step: Extract<FlowStep, { kind: 'condition' }>;
path: FlowPath;
connectors: Connector[];
fields: TriggerField[];
scopes: Map<string, TriggerField[]>;
fallbackFields: TriggerField[];
available: TriggerField[];
canMoveUp: boolean;
canMoveDown: boolean;
}) {
@@ -459,7 +500,8 @@ function ConditionCard({
steps={step[branch]}
path={[...path, { stepId: step.id, branch }]}
connectors={connectors}
fields={fields}
scopes={scopes}
fallbackFields={fallbackFields}
compact
{...callbacks}
/>
@@ -470,14 +512,6 @@ function ConditionCard({
);
}
function StepShell({ children }: { children: ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
{children}
</div>
);
}
function StepControls({
canMoveUp,
canMoveDown,
@@ -0,0 +1,218 @@
import { AlertTriangle, Braces } from 'lucide-react';
import { useRef, useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { brokenReferences } from '@/lib/flow';
import { cn } from '@/lib/cn';
import type { OperationField, TriggerField } from '@/types/dashboard';
/**
* Nastaveni akce: co se ma kam ulozit.
*
* Hodnota je sablona. `{{nazev}}` se pri behu nahradi parametrem spoustece,
* takze jde rict "do obsahu ticketu dej text zpravy z WhatsApp".
*
* Odkazuje se jmenem parametru, ne jeho ID - jmeno uzivatel vidi a pise.
* Prejmenovani parametru proto sablonu rozbije, coz se tady rovnou ukaze
* a server to hlasi jako nedodelek.
*/
export function StepInputs({
fields,
values,
available,
onChange,
}: {
fields: OperationField[];
values: Record<string, string>;
/** Parametry viditelne prave tady: spoustec plus vystupy predchozich kroku. */
available: TriggerField[];
onChange: (values: Record<string, string>) => void;
}) {
/** Do ktereho pole vlozi kliknuti na parametr. Vyber nema kam vkladat. */
const [focused, setFocused] = useState<string | null>(null);
const elements = useRef(new Map<string, HTMLInputElement | HTMLTextAreaElement>());
function set(fieldId: string, value: string) {
onChange({ ...values, [fieldId]: value });
}
/** Vlozi `{{nazev}}` na pozici kurzoru v naposledy pouzitem poli. */
function insert(name: string) {
if (!focused) return;
const element = elements.current.get(focused);
const current = values[focused] ?? '';
const token = `{{${name}}}`;
if (!element) {
set(focused, current + token);
return;
}
const start = element.selectionStart ?? current.length;
const end = element.selectionEnd ?? start;
set(focused, current.slice(0, start) + token + current.slice(end));
// Kurzor za vlozeny odkaz, aby slo psat dal bez klikani.
window.requestAnimationFrame(() => {
element.focus();
const at = start + token.length;
element.setSelectionRange(at, at);
});
}
const inputClass =
'w-full rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none';
return (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
<h4 className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Co se kam uložit
</h4>
{fields.map((field) => {
const value = values[field.id] ?? '';
const missing = field.required && value.trim().length === 0;
const broken = field.kind === 'choice' ? [] : brokenReferences(value, available);
return (
<div key={field.id}>
<label
htmlFor={`input-${field.id}`}
className="flex flex-wrap items-center gap-2 text-xs text-white/55"
>
{field.label}
{field.required && <span className="text-white/30">povinné</span>}
{missing && <Badge tone="warn">Chybí</Badge>}
</label>
{field.kind === 'choice' ? (
<select
id={`input-${field.id}`}
value={value}
onChange={(event) => set(field.id, event.target.value)}
className={cn(
inputClass,
'mt-1',
missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
>
{/* Prazdna volba jen dokud si uzivatel nevybral. */}
{value === '' && !field.options?.some((option) => option.value === '') && (
<option value="" className="bg-ink-850">
Vyberte
</option>
)}
{field.options?.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
) : field.kind === 'longtext' ? (
<textarea
id={`input-${field.id}`}
ref={(element) => {
if (element) elements.current.set(field.id, element);
else elements.current.delete(field.id);
}}
value={value}
rows={3}
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
className={cn(
inputClass,
'mt-1 resize-y',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
) : (
<input
id={`input-${field.id}`}
ref={(element) => {
if (element) elements.current.set(field.id, element);
else elements.current.delete(field.id);
}}
value={value}
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
className={cn(
inputClass,
'mt-1',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
)}
{broken.length > 0 && (
<p className="mt-1 flex items-start gap-1.5 text-xs text-warn-400">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
Spouštěč nemá parametr {broken.map((name) => `{{${name}}}`).join(', ')}.
Nejspíš se přejmenoval.
</p>
)}
</div>
);
})}
<ParameterChips
available={available}
enabled={focused !== null}
onInsert={insert}
/>
</div>
);
}
/** Nabidka parametru spoustece. Kliknuti je vlozi do rozepsaneho pole. */
function ParameterChips({
available,
enabled,
onInsert,
}: {
available: TriggerField[];
enabled: boolean;
onInsert: (name: string) => void;
}) {
if (available.length === 0) {
return (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2 text-xs text-white/35">
Sem zatím nedoteče žádný parametr, takže do polí jde psát jen pevný text.
</p>
);
}
return (
<div className="rounded-lg border border-ink-600/50 bg-ink-900/40 px-3 py-2.5">
<p className="flex items-center gap-1.5 text-xs text-white/40">
<Braces className="size-3" />
{enabled
? 'Kliknutím vložíte parametr spouštěče na pozici kurzoru.'
: 'Klikněte do pole výše a pak sem, parametr se vloží.'}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{available.map((field) => (
<button
key={field.id}
type="button"
disabled={!enabled}
onClick={() => onInsert(field.name)}
title={`Vložit {{${field.name}}}`}
className={cn(
'rounded-md border px-2 py-1 font-mono text-xs transition-colors',
enabled
? 'border-brand-400/40 text-brand-200 hover:border-brand-400 hover:bg-brand-500/15'
: 'cursor-not-allowed border-ink-600/60 text-white/25',
)}
>
{field.name || '(bez názvu)'}
</button>
))}
</div>
</div>
);
}
+4
View File
@@ -2,10 +2,12 @@ import {
BarChart3,
Building2,
Clock,
Facebook,
FileAudio,
FileInput,
Globe,
Hash,
Instagram,
Landmark,
LifeBuoy,
Mail,
@@ -38,10 +40,12 @@ const icons: Record<string, LucideIcon> = {
BarChart3,
Building2,
Clock,
Facebook,
FileAudio,
FileInput,
Globe,
Hash,
Instagram,
Landmark,
LifeBuoy,
Mail,
+92
View File
@@ -1,4 +1,5 @@
import type {
AutomationFlow,
ConditionOperator,
Connector,
ConnectorOperation,
@@ -126,6 +127,97 @@ export function describeCondition(
return `${field.name} ${label} ${value?.trim() || '…'}`;
}
// -------------------------------------------------------- co je kde videt
/**
* Co je videt v kterem miste stromu. Spoustec da nejake parametry, kazda akce
* muze pridat dalsi - "dohledat firmu" vrati `customerKnown`.
*
* Vetev podminky NEPRIDAVA nic do sekvence za podminkou, protoze nemusela
* probehnout. POZOR: stejna logika je na serveru v src/data/flowScope.ts,
* server je autorita.
*/
export function collectScopes(
flow: AutomationFlow,
connectors: Connector[],
): Map<string, TriggerField[]> {
const byStep = new Map<string, TriggerField[]>();
const triggerFields = flow.trigger?.fields ?? [];
const outputsOf = (step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] => {
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
return (resolved?.operation.outputFields ?? []).map((output) => ({
// Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt.
id: `${step.id}.${output.id}`,
name: output.name,
type: output.type,
required: output.required,
}));
};
const walk = (steps: FlowStep[], inherited: TriggerField[]) => {
let available = inherited;
for (const step of steps) {
byStep.set(step.id, available);
if (step.kind === 'condition') {
walk(step.yes, available);
walk(step.no, available);
continue;
}
const outputs = outputsOf(step);
if (outputs.length > 0) available = [...available, ...outputs];
}
};
walk(flow.steps, triggerFields);
return byStep;
}
// ----------------------------------------------------------------- sablony
/**
* `{{nazev}}` v hodnote pole akce se pri behu nahradi parametrem spoustece.
* POZOR: stejna logika je na serveru v src/data/templates.ts, server je autorita.
*/
const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit. */
export function referencedFields(template: string): string[] {
const found: string[] = [];
for (const match of template.matchAll(REFERENCE)) {
const name = match[1];
if (name && !found.includes(name)) found.push(name);
}
return found;
}
/** Odkazy na parametry, ktere u spoustece neexistuji. Typicky po prejmenovani. */
export function brokenReferences(template: string, fields: TriggerField[]): string[] {
const known = new Set(fields.map((field) => field.name));
return referencedFields(template).filter((name) => !known.has(name));
}
/** Zmena nastaveni akce kdekoliv ve strome. */
export function updateStepInputs(
steps: FlowStep[],
stepId: string,
inputs: Record<string, string>,
): FlowStep[] {
return steps.map((step) => {
if (step.kind === 'condition') {
return {
...step,
yes: updateStepInputs(step.yes, stepId, inputs),
no: updateStepInputs(step.no, stepId, inputs),
};
}
return step.id === stepId ? { ...step, inputs } : step;
});
}
/** Zmena vlastnosti podminky kdekoliv ve strome. */
export function updateCondition(
steps: FlowStep[],
@@ -15,6 +15,7 @@ import {
moveStep,
removeStep,
updateCondition,
updateStepInputs,
type FlowPath,
} from '@/lib/flow';
import { formatDateTime } from '@/lib/format';
@@ -361,6 +362,9 @@ export default function AutomationDetail() {
onUpdateCondition={(stepId, patch) =>
changeFlow({ ...flow, steps: updateCondition(flow.steps, stepId, patch) })
}
onUpdateInputs={(stepId, inputs) =>
changeFlow({ ...flow, steps: updateStepInputs(flow.steps, stepId, inputs) })
}
onMoveStep={(stepId, offset) =>
changeFlow({ ...flow, steps: moveStep(flow.steps, stepId, offset) })
}
+35 -3
View File
@@ -1,4 +1,12 @@
import { AlertCircle, ArrowLeft, Building2, MessageSquarePlus, ScrollText, UserCheck } from 'lucide-react';
import {
AlertCircle,
ArrowLeft,
Building2,
FileText,
MessageSquarePlus,
ScrollText,
UserCheck,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
@@ -126,7 +134,30 @@ export default function TicketDetail() {
)}
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<section className="glass rounded-card p-5 sm:p-6">
<div className="space-y-6">
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="flex items-center gap-2 font-semibold text-white">
<FileText className="size-4 text-brand-300" />
Obsah požadavku
</h2>
{ticket.data.body.trim().length > 0 ? (
<p className="mt-3 text-sm leading-relaxed whitespace-pre-wrap text-white/75">
{ticket.data.body}
</p>
) : (
<p className="mt-3 rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
Prázdné. Krok Založit ticket" neměl nastavené pole Obsah, takže se text
původního požadavku nikam neuložil.
</p>
)}
{ticket.data.sourceRef && (
<p className="mt-3 font-mono text-xs text-white/30">
zdroj {ticket.data.sourceRef}
</p>
)}
</section>
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
<ScrollText className="size-4 text-brand-300" />
@@ -167,7 +198,8 @@ export default function TicketDetail() {
Komentář se zapíše do stejné časové osy jako běh automatizace.
</p>
</form>
</section>
</section>
</div>
<aside className="space-y-4">
<div className="glass rounded-card p-5">
+2
View File
@@ -32,6 +32,8 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'email', label: 'E-mail' },
{ value: 'voice', label: 'Hlasová linka' },
{ value: 'form', label: 'Formulář' },
+31 -1
View File
@@ -6,7 +6,14 @@
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
/** Odkud pozadavek prisel. */
export type TicketChannel = 'whatsapp' | 'email' | 'voice' | 'form' | 'portal';
export type TicketChannel =
| 'whatsapp'
| 'facebook'
| 'instagram'
| 'email'
| 'voice'
| 'form'
| 'portal';
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
@@ -31,6 +38,10 @@ export interface TicketCustomer {
export interface Ticket {
id: string;
subject: string;
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
body: string;
/** Odkaz na zdrojovou zpravu u poskytovatele. */
sourceRef: string | null;
channel: TicketChannel;
customer: TicketCustomer;
status: TicketStatus;
@@ -151,6 +162,23 @@ export interface ConnectorOperation {
* server je pri ukladani stromu vzdy dosadi z katalogu.
*/
providedFields?: TriggerField[];
/** Jen u akci: skutecne nastavitelna pole. Kdyz chybi, akci nejde nastavit. */
inputs?: OperationField[];
/** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */
outputFields?: TriggerField[];
}
/**
* Nastavitelne pole akce. Hodnota je sablona - `{{nazev}}` se nahradi
* parametrem spoustece.
*/
export interface OperationField {
id: string;
label: string;
kind: 'text' | 'longtext' | 'choice';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
export interface Connector {
@@ -218,6 +246,8 @@ export type FlowStep =
kind: 'action';
connectorId: string;
operationId: string;
/** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */
inputs?: Record<string, string>;
}
| {
id: string;