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:
@@ -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 && (
|
||||
<> Až 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 má 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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ář' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user