Rozdeleni na sluzby a konektory, pristupove udaje do konektoru
Slovo "konektor" v kodu znamenalo katalog toho, co umime. Ted znamena napojeni jedne firmy, tedy to, co tim mysli i uzivatel. Popis modelu je v documentation/12-sluzby-a-konektory.md. Tri vrstvy: - Sluzba: ze iDoklad existuje, co umi a co potrebuje k napojeni. Nase. - Skript: kod, ktery jednu operaci sluzby opravdu vykona. Nas. - Konektor: ucet firmy vcetne jejich pristupovych udaju. Firemni. Pristupove udaje se prestaly cist z environment variables. Cela instance by mela jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu. Napojeni je vlastnost firmy, ne prostredi. Z prostredi zustava jen SERVICES_BASE_URL. Pridano: - src/data/services.ts: sluzba nese general, appId, visibility, credentials a verifyPath. Kategorie "obecne" sdruzuje veci, ktere ma kazdy a nepotrebuji konektor: webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, zapis do logu - viditelnost sluzby: vsichni, jen uvedene firmy a lide, nebo jen spravce platformy. Neviditelna sluzba se z API nevraci vubec, ne se stavem 403 - firma nema poznat, ze takova sluzba existuje - src/data/connectorStore.ts: konektory za firmu vcetne hodnot udaju. Hodnoty se z API nikdy nevraci, jen filled a missing. Prazdne pole hodnotu nemeni, takze ulozeni formularu bez tajnych hodnot nic nepresepe - FlowStep.connectorId: krok rika, pod kterym napojenim volat. null = vychozi konektor firmy, diky tomu je vzorovy strom prenositelny mezi firmami - overeni konektoru pres verifyPath, tedy cteci volani vyzadujici autorizaci. U sluzby bez nej se overi jen dostupnost a odpoved to rekne nahlas, jinak by zeleny vysledek uzivateli lhal - stranky /dashboard/sluzby a /dashboard/konektory vcetne formularu udaju - endpointy /api/dashboard/services a CRUD /api/dashboard/connectors ve Swaggeru - predvyplnene prihlaseni spravcem platformy a prepinac demo uctu na login strance, kvuli testovani prototypu Zmeneno: - stav "napojeno" se prestal cist z katalogu a zacal pocitat z konektoru firmy. Sluzba ma jen available nebo planned - validace stromu overuje i konektor. Cizi konektor je chyba, chybejici napojeni nedodelek - rozdelana prace se nezahazuje - prejmenovani napric kodem: Connector na Service, FlowStep.connectorId na serviceId, GET /connectors na GET /services, connectorIcons na serviceIcons, stranka Konektory (katalog) na Sluzby. Prevodni tabulka je v dokumentu 12 Overeno: npm run typecheck prochazi na serveru i webu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6f6b287d7e
commit
8ad91a6c28
@@ -16,6 +16,7 @@ import Services from '@/pages/Services';
|
||||
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
|
||||
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
|
||||
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
|
||||
const DashboardServices = lazy(() => import('@/pages/dashboard/Services'));
|
||||
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
|
||||
const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
|
||||
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="automatizace" element={<Automations />} />
|
||||
<Route path="automatizace/:id" element={<AutomationDetail />} />
|
||||
<Route path="sluzby" element={<DashboardServices />} />
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="skripty" element={<Scripts />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
Plug,
|
||||
Blocks,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Workflow,
|
||||
@@ -27,6 +28,7 @@ import type { Access } from '@/types/dashboard';
|
||||
const nav = [
|
||||
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
|
||||
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
|
||||
{ to: '/dashboard/sluzby', label: 'Služby', icon: Blocks, end: false },
|
||||
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
|
||||
{ to: '/dashboard/skripty', label: 'Skripty', icon: ScrollText, end: false },
|
||||
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
|
||||
|
||||
@@ -2,9 +2,9 @@ import { AlertCircle, CheckCircle2, GitBranch, Info, MinusCircle, Zap } from 'lu
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import { formatDateTime, formatDuration } from '@/lib/format';
|
||||
import type { Connector, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
|
||||
import type { Service, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Log ticketu jako strom. Vetev podminky je zanorena pod ni,
|
||||
@@ -55,10 +55,10 @@ function buildTree(entries: TicketTraceEntry[]): TraceNode[] {
|
||||
|
||||
export function TicketTrace({
|
||||
entries,
|
||||
connectors,
|
||||
services,
|
||||
}: {
|
||||
entries: TicketTraceEntry[];
|
||||
connectors: Connector[];
|
||||
services: Service[];
|
||||
}) {
|
||||
const tree = useMemo(() => buildTree(entries), [entries]);
|
||||
|
||||
@@ -73,16 +73,16 @@ export function TicketTrace({
|
||||
return (
|
||||
<ol className="space-y-2.5">
|
||||
{tree.map((node) => (
|
||||
<TraceRow key={node.id} node={node} connectors={connectors} />
|
||||
<TraceRow key={node.id} node={node} services={services} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector[] }) {
|
||||
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 service = describeService(node, connectors);
|
||||
const service = describeService(node, services);
|
||||
|
||||
return (
|
||||
<li>
|
||||
@@ -124,7 +124,7 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector
|
||||
{node.children.length > 0 && (
|
||||
<ol className={cn('mt-2.5 space-y-2.5 border-l-2 pl-4 sm:pl-6', meta.line)}>
|
||||
{node.children.map((child) => (
|
||||
<TraceRow key={child.id} node={child} connectors={connectors} />
|
||||
<TraceRow key={child.id} node={child} services={services} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
@@ -133,11 +133,11 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector
|
||||
}
|
||||
|
||||
/** "RAYNET CRM / Dohledani kontaktu" - kdyz konektor v katalogu neni, aspon jeho ID. */
|
||||
function describeService(node: TraceNode, connectors: Connector[]): string | null {
|
||||
if (!node.connectorId) return null;
|
||||
function describeService(node: TraceNode, services: Service[]): string | null {
|
||||
if (!node.serviceId) return null;
|
||||
|
||||
const connector = connectors.find((c) => c.id === node.connectorId);
|
||||
if (!connector) return `${node.connectorId}${node.operationId ? ` / ${node.operationId}` : ''}`;
|
||||
const connector = services.find((c) => c.id === node.serviceId);
|
||||
if (!connector) return `${node.serviceId}${node.operationId ? ` / ${node.operationId}` : ''}`;
|
||||
|
||||
if (!node.operationId) return connector.name;
|
||||
|
||||
@@ -150,7 +150,7 @@ function describeService(node: TraceNode, connectors: Connector[]): string | nul
|
||||
export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
|
||||
const failed = entries.filter((e) => e.status === 'error').length;
|
||||
const skipped = entries.filter((e) => e.status === 'skipped').length;
|
||||
const services = new Set(entries.map((e) => e.connectorId).filter(Boolean)).size;
|
||||
const services = new Set(entries.map((e) => e.serviceId).filter(Boolean)).size;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-white/40">
|
||||
@@ -162,8 +162,8 @@ export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ikona konektoru pro pouziti nad logem (napr. u kanalu ticketu). */
|
||||
export function serviceIcon(connectorId: string, connectors: Connector[]): LucideIcon | null {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
return connector ? connectorIcon(connector.icon) : null;
|
||||
/** Ikona sluzby pro pouziti nad logem (napr. u kanalu ticketu). */
|
||||
export function traceServiceIcon(serviceId: string, services: Service[]): LucideIcon | null {
|
||||
const service = services.find((item) => item.id === serviceId);
|
||||
return service ? serviceIcon(service.icon) : null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { serviceIcon } from '@/lib/serviceIcons';
|
||||
import {
|
||||
collectScopes,
|
||||
defaultOperatorFor,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type {
|
||||
AutomationFlow,
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
Service,
|
||||
FlowStep,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
@@ -51,7 +51,7 @@ export function FlowCanvas({
|
||||
...callbacks
|
||||
}: CanvasCallbacks & {
|
||||
flow: AutomationFlow;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
webhookBaseUrl: string;
|
||||
regenerating: boolean;
|
||||
onPickTrigger: () => void;
|
||||
@@ -95,7 +95,7 @@ function TriggerCard({
|
||||
onRegenerateToken,
|
||||
}: {
|
||||
flow: AutomationFlow;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
webhookBaseUrl: string;
|
||||
regenerating: boolean;
|
||||
onPick: () => void;
|
||||
@@ -124,7 +124,7 @@ function TriggerCard({
|
||||
|
||||
const resolved = resolveOperation(
|
||||
connectors,
|
||||
flow.trigger.connectorId,
|
||||
flow.trigger.serviceId,
|
||||
flow.trigger.operationId,
|
||||
'trigger',
|
||||
);
|
||||
@@ -133,13 +133,13 @@ function TriggerCard({
|
||||
return (
|
||||
<BrokenCard
|
||||
label="Spouštěč odkazuje na neznámý konektor"
|
||||
detail={`${flow.trigger.connectorId} / ${flow.trigger.operationId}`}
|
||||
detail={`${flow.trigger.serviceId} / ${flow.trigger.operationId}`}
|
||||
onFix={onPick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = connectorIcon(resolved.connector.icon);
|
||||
const Icon = serviceIcon(resolved.connector.icon);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
|
||||
@@ -191,7 +191,7 @@ function StepSequence({
|
||||
}: CanvasCallbacks & {
|
||||
steps: FlowStep[];
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
/** Co je videt v kterem kroku. Klic je ID kroku. */
|
||||
scopes: Map<string, TriggerField[]>;
|
||||
/** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */
|
||||
@@ -279,7 +279,7 @@ function ActionCard({
|
||||
onUpdateInputs,
|
||||
}: {
|
||||
step: Extract<FlowStep, { kind: 'action' }>;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
/** Parametry viditelne prave v tomto miste stromu. */
|
||||
available: TriggerField[];
|
||||
canMoveUp: boolean;
|
||||
@@ -288,19 +288,19 @@ function ActionCard({
|
||||
onMove: (offset: number) => void;
|
||||
onUpdateInputs: (inputs: Record<string, string>) => void;
|
||||
}) {
|
||||
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
|
||||
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<BrokenCard
|
||||
label="Krok odkazuje na neznámou akci"
|
||||
detail={`${step.connectorId} / ${step.operationId}`}
|
||||
detail={`${step.serviceId} / ${step.operationId}`}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = connectorIcon(resolved.connector.icon);
|
||||
const Icon = serviceIcon(resolved.connector.icon);
|
||||
const inputs = resolved.operation.inputs;
|
||||
|
||||
return (
|
||||
@@ -359,7 +359,7 @@ function ConditionCard({
|
||||
}: CanvasCallbacks & {
|
||||
step: Extract<FlowStep, { kind: 'condition' }>;
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
scopes: Map<string, TriggerField[]>;
|
||||
fallbackFields: TriggerField[];
|
||||
available: TriggerField[];
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import type { Connector, ConnectorCategory, ConnectorOperation } from '@/types/dashboard';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import type { Service, ServiceCategory, ServiceOperation } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vyber toho, co se ma pridat do stromu.
|
||||
@@ -28,19 +28,19 @@ export function StepPicker({
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: 'trigger' | 'action';
|
||||
connectors: Connector[];
|
||||
categories: Array<{ id: ConnectorCategory; label: string }>;
|
||||
connectors: Service[];
|
||||
categories: Array<{ id: ServiceCategory; label: string }>;
|
||||
onClose: () => void;
|
||||
onPickOperation: (connectorId: string, operationId: string) => void;
|
||||
onPickOperation: (serviceId: string, operationId: string) => void;
|
||||
onPickCondition?: () => void;
|
||||
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
|
||||
canAddCondition?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Connector | null>(null);
|
||||
const [selected, setSelected] = useState<Service | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
|
||||
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
|
||||
|
||||
const operationsOf = (connector: Connector) =>
|
||||
const operationsOf = (connector: Service) =>
|
||||
mode === 'trigger' ? connector.triggers : connector.actions;
|
||||
|
||||
// Konektory, ktere v tomhle rezimu vubec maji co nabidnout.
|
||||
@@ -65,7 +65,7 @@ export function StepPicker({
|
||||
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searching) return [];
|
||||
const results: Array<{ connector: Connector; operation: ConnectorOperation }> = [];
|
||||
const results: Array<{ connector: Service; operation: ServiceOperation }> = [];
|
||||
for (const connector of usable) {
|
||||
for (const operation of operationsOf(connector)) {
|
||||
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
|
||||
@@ -82,7 +82,7 @@ export function StepPicker({
|
||||
onClose();
|
||||
}
|
||||
|
||||
function pick(connector: Connector, operation: ConnectorOperation) {
|
||||
function pick(connector: Service, operation: ServiceOperation) {
|
||||
if (connector.status === 'planned') {
|
||||
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
|
||||
return;
|
||||
@@ -267,8 +267,8 @@ function CategoryChip({
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorGlyph({ connector }: { connector: Connector }) {
|
||||
const Icon = connectorIcon(connector.icon);
|
||||
function ConnectorGlyph({ connector }: { connector: Service }) {
|
||||
const Icon = serviceIcon(connector.icon);
|
||||
return (
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
@@ -281,7 +281,7 @@ function ConnectorCard({
|
||||
operationCount,
|
||||
onClick,
|
||||
}: {
|
||||
connector: Connector;
|
||||
connector: Service;
|
||||
operationCount: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -324,8 +324,8 @@ function OperationRow({
|
||||
showConnectorName = false,
|
||||
onClick,
|
||||
}: {
|
||||
connector: Connector;
|
||||
operation: ConnectorOperation;
|
||||
connector: Service;
|
||||
operation: ServiceOperation;
|
||||
showConnectorName?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
|
||||
import type { Connector, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
|
||||
import type { Service, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
|
||||
|
||||
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
|
||||
|
||||
@@ -20,13 +20,13 @@ export function TriggerConfig({
|
||||
regenerating,
|
||||
}: {
|
||||
trigger: FlowTrigger;
|
||||
connector: Connector | undefined;
|
||||
connector: Service | undefined;
|
||||
webhookBaseUrl: string;
|
||||
onChangeFields: (fields: TriggerField[]) => void;
|
||||
onRegenerateToken: () => void;
|
||||
regenerating: boolean;
|
||||
}) {
|
||||
const isWebhook = trigger.connectorId === 'webhook';
|
||||
const isWebhook = trigger.serviceId === 'webhook';
|
||||
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
|
||||
const editable = operation?.customPayload === true;
|
||||
/**
|
||||
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
AutomationFlow,
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
ConnectorOperation,
|
||||
Service,
|
||||
ServiceOperation,
|
||||
FieldType,
|
||||
FlowStep,
|
||||
TriggerField,
|
||||
@@ -30,8 +30,8 @@ export function newStepId(): string {
|
||||
return `st_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
export function createActionStep(connectorId: string, operationId: string): FlowStep {
|
||||
return { id: newStepId(), kind: 'action', connectorId, operationId };
|
||||
export function createActionStep(serviceId: string, operationId: string): FlowStep {
|
||||
return { id: newStepId(), kind: 'action', serviceId, operationId };
|
||||
}
|
||||
|
||||
export function createConditionStep(field: TriggerField): FlowStep {
|
||||
@@ -139,13 +139,13 @@ export function describeCondition(
|
||||
*/
|
||||
export function collectScopes(
|
||||
flow: AutomationFlow,
|
||||
connectors: Connector[],
|
||||
connectors: Service[],
|
||||
): 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');
|
||||
const resolved = resolveOperation(connectors, step.serviceId, 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}`,
|
||||
@@ -305,8 +305,8 @@ export function countSteps(steps: FlowStep[]): number {
|
||||
}
|
||||
|
||||
interface ResolvedOperation {
|
||||
connector: Connector;
|
||||
operation: ConnectorOperation;
|
||||
connector: Service;
|
||||
operation: ServiceOperation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,21 +314,21 @@ interface ResolvedOperation {
|
||||
* (napr. konektor byl z katalogu odebran) - volajici to musi zobrazit.
|
||||
*/
|
||||
export function resolveOperation(
|
||||
connectors: Connector[],
|
||||
connectorId: string,
|
||||
connectors: Service[],
|
||||
serviceId: string,
|
||||
operationId: string,
|
||||
type: 'trigger' | 'action',
|
||||
): ResolvedOperation | null {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
const connector = connectors.find((c) => c.id === serviceId);
|
||||
if (!connector) {
|
||||
console.warn(`[flow] neznamy konektor ${connectorId}`);
|
||||
console.warn(`[flow] neznamy konektor ${serviceId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const pool = type === 'trigger' ? connector.triggers : connector.actions;
|
||||
const operation = pool.find((op) => op.id === operationId);
|
||||
if (!operation) {
|
||||
console.warn(`[flow] konektor ${connectorId} nema ${type} ${operationId}`);
|
||||
console.warn(`[flow] konektor ${serviceId} nema ${type} ${operationId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Building2,
|
||||
Clock,
|
||||
Facebook,
|
||||
@@ -32,12 +33,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* API posila u konektoru jen klic ikony (string) - React komponentu poslat nemuze.
|
||||
* API posila u sluzby jen klic ikony (string) - React komponentu poslat nemuze.
|
||||
* Mapovani je tady. Neznamy klic se zaloguje a dostane obecnou ikonu,
|
||||
* aby chybejici zapis nerozbil celou stranku.
|
||||
*/
|
||||
const icons: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Building2,
|
||||
Clock,
|
||||
Facebook,
|
||||
@@ -67,10 +69,10 @@ const icons: Record<string, LucideIcon> = {
|
||||
Webhook,
|
||||
};
|
||||
|
||||
export function connectorIcon(key: string): LucideIcon {
|
||||
export function serviceIcon(key: string): LucideIcon {
|
||||
const icon = icons[key];
|
||||
if (!icon) {
|
||||
console.warn(`[connectorIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
|
||||
console.warn(`[serviceIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
|
||||
return Plug;
|
||||
}
|
||||
return icon;
|
||||
+51
-3
@@ -8,6 +8,24 @@ import { Button } from '@/components/ui/Button';
|
||||
import { brand } from '@/config/brand';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
|
||||
/**
|
||||
* Demo ucty prototypu.
|
||||
*
|
||||
* Prvni je predvyplneny, aby se pri testovani nemuselo porad psat prihlaseni.
|
||||
* Pred ostrym pouzitim cely tenhle blok i `defaultValue` odebrat.
|
||||
*/
|
||||
interface DemoAccount {
|
||||
email: string;
|
||||
password: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const demoAccounts: DemoAccount[] = [
|
||||
{ email: 'admin@automia.cz', password: 'demo1234', note: 'správce platformy, vidí všechny firmy' },
|
||||
{ email: 'karel.vomacka@automia.cz', password: 'demo1234', note: 'dvě firmy, jiná role v každé' },
|
||||
{ email: 'martin.kriz@automia.cz', password: 'demo1234', note: 'řešitel jedné firmy' },
|
||||
];
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
@@ -22,6 +40,20 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Predvyplni formular vybranym demo uctem.
|
||||
* Sahame na DOM misto stavu, protoze formular je nerizeny - jinak by se
|
||||
* musela prepsat obe pole na controlled a nic dalsiho z toho nezískáme.
|
||||
*/
|
||||
function fillAccount(account: DemoAccount) {
|
||||
const form = document.querySelector<HTMLFormElement>('form');
|
||||
if (!form) return;
|
||||
const email = form.elements.namedItem('email');
|
||||
const password = form.elements.namedItem('password');
|
||||
if (email instanceof HTMLInputElement) email.value = account.email;
|
||||
if (password instanceof HTMLInputElement) password.value = account.password;
|
||||
}
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Kam po prihlaseni - bud puvodni cil z RequireAuth, nebo dashboard.
|
||||
@@ -86,6 +118,8 @@ export default function Login() {
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
// Predvyplneno kvuli testovani prototypu. Pred ostrym pouzitim odebrat.
|
||||
defaultValue={demoAccounts[0].email}
|
||||
className={inputClass}
|
||||
placeholder="vas@email.cz"
|
||||
/>
|
||||
@@ -104,6 +138,7 @@ export default function Login() {
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
defaultValue={demoAccounts[0].password}
|
||||
className={inputClass}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@@ -127,9 +162,22 @@ export default function Login() {
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Demo přístupy (prototyp)
|
||||
</p>
|
||||
<ul className="mt-2.5 space-y-1 font-mono text-xs text-white/50">
|
||||
<li>admin@automia.cz / demo1234</li>
|
||||
<li>klient@firma.cz / demo1234</li>
|
||||
<p className="mt-1 text-xs text-white/35">
|
||||
Formulář je předvyplněný správcem platformy. Kliknutím se přepne účet.
|
||||
</p>
|
||||
<ul className="mt-2.5 space-y-1">
|
||||
{demoAccounts.map((account) => (
|
||||
<li key={account.email}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillAccount(account)}
|
||||
className="w-full rounded-lg px-2 py-1 text-left font-mono text-xs text-white/50 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
{account.email}
|
||||
<span className="ml-2 font-sans text-white/30">{account.note}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
AutomationDetail as Detail,
|
||||
AutomationFlow,
|
||||
ConnectorCatalog,
|
||||
ServiceCatalog,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AutomationDetail() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
|
||||
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
|
||||
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [flow, setFlow] = useState<AutomationFlow>({ trigger: null, steps: [] });
|
||||
@@ -79,14 +79,14 @@ export default function AutomationDetail() {
|
||||
}, []);
|
||||
|
||||
/** Parametry, ktere ma mit spoustec po jeho zmene. */
|
||||
function fieldsForTrigger(connectorId: string, operationId: string): TriggerField[] {
|
||||
const provided = findTrigger(connectorId, operationId)?.providedFields;
|
||||
function fieldsForTrigger(serviceId: string, operationId: string): TriggerField[] {
|
||||
const provided = findTrigger(serviceId, operationId)?.providedFields;
|
||||
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
|
||||
// tady je nastavime hned, aby slo rovnou stavet podminky.
|
||||
if (provided) return provided;
|
||||
|
||||
const previous = flow.trigger
|
||||
? findTrigger(flow.trigger.connectorId, flow.trigger.operationId)
|
||||
? findTrigger(flow.trigger.serviceId, flow.trigger.operationId)
|
||||
: undefined;
|
||||
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
|
||||
if (previous?.providedFields) return [];
|
||||
@@ -95,13 +95,13 @@ export default function AutomationDetail() {
|
||||
return flow.trigger?.fields ?? [];
|
||||
}
|
||||
|
||||
function findTrigger(connectorId: string, operationId: string) {
|
||||
function findTrigger(serviceId: string, operationId: string) {
|
||||
return connectors
|
||||
.find((connector) => connector.id === connectorId)
|
||||
.find((connector) => connector.id === serviceId)
|
||||
?.triggers.find((operation) => operation.id === operationId);
|
||||
}
|
||||
|
||||
function handlePick(connectorId: string, operationId: string) {
|
||||
function handlePick(serviceId: string, operationId: string) {
|
||||
if (!picker) {
|
||||
console.warn('[builder] vyber potvrzen bez otevreneho cile');
|
||||
return;
|
||||
@@ -111,9 +111,9 @@ export default function AutomationDetail() {
|
||||
changeFlow({
|
||||
...flow,
|
||||
trigger: {
|
||||
connectorId,
|
||||
serviceId,
|
||||
operationId,
|
||||
fields: fieldsForTrigger(connectorId, operationId),
|
||||
fields: fieldsForTrigger(serviceId, operationId),
|
||||
webhookToken: flow.trigger?.webhookToken,
|
||||
},
|
||||
});
|
||||
@@ -124,7 +124,7 @@ export default function AutomationDetail() {
|
||||
flow.steps,
|
||||
picker.path,
|
||||
picker.index,
|
||||
createActionStep(connectorId, operationId),
|
||||
createActionStep(serviceId, operationId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,147 +1,130 @@
|
||||
import { CheckCircle2, Clock, Plug, Search, Terminal, Zap } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Plus, Star, Trash2, Wifi } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { brand } from '@/config/brand';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { apiFetch, ApiError } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
Connector,
|
||||
ConnectorCatalog,
|
||||
ConnectorCategory,
|
||||
ConnectorOperation,
|
||||
ConnectorStatus,
|
||||
ConnectorTestResult,
|
||||
Service,
|
||||
ServiceCredentialField,
|
||||
ServiceOverview,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
available: { label: 'Umíme napojit', tone: 'neutral' },
|
||||
planned: { label: 'Na roadmapě', tone: 'warn' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Konektory: napojeni teto firmy na sluzby.
|
||||
*
|
||||
* Pristupove udaje se zadavaji tady, ne v prostredi aplikace - kazda firma ma
|
||||
* svoje. Tajne hodnoty server nikdy nevraci, takze u vyplneneho pole je videt
|
||||
* jen to, ze vyplnene je. Viz documentation/12-sluzby-a-konektory.md.
|
||||
*/
|
||||
export default function Connectors() {
|
||||
usePageMeta({ title: 'Konektory — portál Automia' });
|
||||
usePageMeta({ title: 'Konektory - portál Automia' });
|
||||
|
||||
const { data, loading, error, reload } = useApiQuery<ConnectorCatalog>(
|
||||
'/api/dashboard/connectors',
|
||||
const [params, setParams] = useSearchParams();
|
||||
const preselected = params.get('service');
|
||||
|
||||
const [services, setServices] = useState<ServiceOverview | null>(null);
|
||||
const [connectors, setConnectors] = useState<Connector[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creatingFor, setCreatingFor] = useState<string | null>(preselected);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
apiFetch<ServiceOverview>('/api/dashboard/connectors/services'),
|
||||
apiFetch<{ items: Connector[] }>('/api/dashboard/connectors'),
|
||||
])
|
||||
.then(([overview, list]) => {
|
||||
setServices(overview);
|
||||
setConnectors(list.items);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Konektory se nepodařilo načíst.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const servicesById = useMemo(() => {
|
||||
const map = new Map<string, Service>();
|
||||
for (const service of services?.items ?? []) map.set(service.id, service);
|
||||
return map;
|
||||
}, [services]);
|
||||
|
||||
/** Sluzby, na ktere jde konektor zalozit. Obecne se nenabizeji, nepotrebuji ho. */
|
||||
const connectable = useMemo(
|
||||
() =>
|
||||
(services?.items ?? [])
|
||||
.filter((service) => !service.general && service.status === 'available')
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs')),
|
||||
[services],
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
|
||||
const [status, setStatus] = useState<ConnectorStatus | 'all'>('all');
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return items.filter((connector) => {
|
||||
if (category !== 'all' && connector.category !== category) return false;
|
||||
if (status !== 'all' && connector.status !== status) return false;
|
||||
if (needle.length === 0) return true;
|
||||
const haystack = [
|
||||
connector.name,
|
||||
connector.description,
|
||||
...connector.triggers.map((t) => t.name),
|
||||
...connector.actions.map((a) => a.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [items, query, category, status]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
connected: items.filter((c) => c.status === 'connected').length,
|
||||
available: items.filter((c) => c.status === 'available').length,
|
||||
planned: items.filter((c) => c.status === 'planned').length,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
function closeForm() {
|
||||
setCreatingFor(null);
|
||||
if (preselected) setParams({});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Konektory</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Služby, které jdou použít v automatizacích. Každý konektor nabízí spouštěče
|
||||
(čím běh začne) a akce (co se má stát).
|
||||
</p>
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Konektory</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-white/50">
|
||||
Napojení této firmy na jednotlivé služby. Přístupové údaje zadáváte tady a
|
||||
zůstávají na serveru, prohlížeč je nikdy nedostane zpátky.
|
||||
</p>
|
||||
</div>
|
||||
{connectable.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreatingFor(connectable[0].id)}
|
||||
disabled={creatingFor !== null}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Nový konektor
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<SummaryTile
|
||||
icon={CheckCircle2}
|
||||
value={counts.connected}
|
||||
label="napojeno a připraveno k použití"
|
||||
tone="ok"
|
||||
<DataState loading={loading} error={error} onRetry={load}>
|
||||
<div className="space-y-5">
|
||||
{creatingFor && (
|
||||
<CreateForm
|
||||
services={connectable}
|
||||
initialServiceId={creatingFor}
|
||||
onCancel={closeForm}
|
||||
onCreated={() => {
|
||||
closeForm();
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={Plug}
|
||||
value={counts.available}
|
||||
label="umíme napojit na požádání"
|
||||
tone="brand"
|
||||
/>
|
||||
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Hledat službu, spouštěč nebo akci…"
|
||||
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
|
||||
Všechny kategorie
|
||||
</Chip>
|
||||
{categories.map((cat) => (
|
||||
<Chip
|
||||
key={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={status === 'all'} onClick={() => setStatus('all')}>
|
||||
Vše
|
||||
</Chip>
|
||||
{(['connected', 'available', 'planned'] as const).map((value) => (
|
||||
<Chip key={value} active={status === value} onClick={() => setStatus(value)}>
|
||||
{statusMeta[value].label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{(connectors?.length ?? 0) === 0 && !creatingFor ? (
|
||||
<p className="py-10 text-center text-sm text-white/45">
|
||||
Nic neodpovídá filtru. Chybí vám konektor?{' '}
|
||||
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
|
||||
Napište nám
|
||||
</a>
|
||||
, většinu služeb umíme napojit přes HTTP požadavek.
|
||||
Zatím žádný konektor. Vyberte službu na stránce Služby, nebo použijte
|
||||
tlačítko Nový konektor.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filtered.map((connector) => (
|
||||
<ConnectorCard key={connector.id} connector={connector} />
|
||||
<div className="space-y-4">
|
||||
{(connectors ?? []).map((connector) => (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
service={servicesById.get(connector.serviceId)}
|
||||
onChanged={load}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -151,133 +134,383 @@ export default function Connectors() {
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone,
|
||||
// ------------------------------------------------------------------ zakladani
|
||||
|
||||
function CreateForm({
|
||||
services,
|
||||
initialServiceId,
|
||||
onCancel,
|
||||
onCreated,
|
||||
}: {
|
||||
icon: typeof Plug;
|
||||
value: number;
|
||||
label: string;
|
||||
tone: 'ok' | 'brand' | 'warn';
|
||||
services: Service[];
|
||||
initialServiceId: string;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const tones = {
|
||||
ok: 'bg-ok-500/12 text-ok-400',
|
||||
brand: 'bg-brand-500/12 text-brand-300',
|
||||
warn: 'bg-warn-500/12 text-warn-400',
|
||||
} as const;
|
||||
const [serviceId, setServiceId] = useState(
|
||||
services.some((service) => service.id === initialServiceId)
|
||||
? initialServiceId
|
||||
: (services[0]?.id ?? ''),
|
||||
);
|
||||
const service = services.find((item) => item.id === serviceId);
|
||||
const [name, setName] = useState('');
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nazev se predvyplni podle sluzby, at ho nikdo nemusi vymyslet.
|
||||
useEffect(() => {
|
||||
setName(service ? service.name : '');
|
||||
setValues({});
|
||||
}, [service]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!service) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/api/dashboard/connectors', {
|
||||
method: 'POST',
|
||||
body: { serviceId: service.id, name: name.trim() || service.name, values },
|
||||
});
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Konektor se nepodařilo založit.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass flex items-center gap-4 rounded-card p-5">
|
||||
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
|
||||
<p className="text-xs text-white/45">{label}</p>
|
||||
<div className="glass rounded-card border border-brand-400/30 p-5">
|
||||
<p className="mb-4 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Nový konektor
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Služba">
|
||||
<select
|
||||
value={serviceId}
|
||||
onChange={(event) => setServiceId(event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{services.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Název konektoru" hint="Firma může mít dva účty téže služby.">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{service && service.credentials.length > 0 && (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{service.credentials.map((field) => (
|
||||
<CredentialInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ''}
|
||||
filled={false}
|
||||
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service && service.credentials.length === 0 && (
|
||||
<p className="mt-4 text-sm text-white/45">
|
||||
U služby {service.name} ještě nejsou popsané přístupové údaje. Konektor půjde
|
||||
založit, ale volat se bude umět až spolu se službou.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
|
||||
<div className="mt-5 flex gap-3">
|
||||
<Button size="sm" onClick={handleCreate} disabled={saving || !service}>
|
||||
{saving ? 'Zakládám...' : 'Založit'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onCancel} disabled={saving}>
|
||||
Zrušit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
// --------------------------------------------------------------------- detail
|
||||
|
||||
function ConnectorCard({
|
||||
connector,
|
||||
service,
|
||||
onChanged,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
connector: Connector;
|
||||
service: Service | undefined;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
|
||||
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const Icon = serviceIcon(service?.icon ?? 'Plug');
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [name, setName] = useState(connector.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [test, setTest] = useState<ConnectorTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function ConnectorCard({ connector }: { connector: Connector }) {
|
||||
const Icon = connectorIcon(connector.icon);
|
||||
const meta = statusMeta[connector.status];
|
||||
const dirty = Object.keys(values).length > 0 || name !== connector.name;
|
||||
|
||||
async function patch(body: Record<string, unknown>) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'PATCH', body });
|
||||
setValues({});
|
||||
setTest(null);
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiError ? err.message : 'Uložení selhalo.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTest(
|
||||
await apiFetch<ConnectorTestResult>(`/api/dashboard/connectors/${connector.id}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ověření se nepodařilo odeslat.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'DELETE' });
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="glass flex h-full flex-col rounded-card p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<article className="glass rounded-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-white">{connector.name}</h2>
|
||||
<p className="mt-0.5 text-sm text-white/55">{connector.description}</p>
|
||||
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
|
||||
{connector.name}
|
||||
{connector.isDefault && (
|
||||
<Star className="size-3.5 text-brand-300" aria-label="Výchozí konektor" />
|
||||
)}
|
||||
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-white/45">
|
||||
{service?.name ?? connector.serviceId}
|
||||
{connector.baseUrl ? ` - ${connector.baseUrl}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={meta.tone}>{meta.label}</Badge>
|
||||
|
||||
<StatusBadge connector={connector} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
operations={connector.triggers}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
operations={connector.actions}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
{connector.missing.length > 0 && service && (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Chybí vyplnit:{' '}
|
||||
{connector.missing
|
||||
.map((id) => service.credentials.find((field) => field.id === id)?.label ?? id)
|
||||
.join(', ')}
|
||||
. Dokud to nevyplníte, kroky s touhle službou nepoběží.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{connector.lastError && (
|
||||
<p className="mt-3 flex items-start gap-2 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
{connector.lastError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<Field label="Název">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{(service?.credentials ?? []).map((field) => (
|
||||
<CredentialInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ''}
|
||||
filled={connector.filled.includes(field.id)}
|
||||
existing={connector.config[field.id]}
|
||||
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
|
||||
{test && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-3 flex items-start gap-2 rounded-lg p-3 text-sm',
|
||||
test.ok
|
||||
? 'border border-ok-500/40 bg-ok-500/8 text-ok-400'
|
||||
: 'border border-danger-500/40 bg-danger-500/8 text-danger-400',
|
||||
)}
|
||||
>
|
||||
{test.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{test.message}
|
||||
<span className="mt-0.5 block text-xs text-white/40">Ověřeno: {test.checked}</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => patch({ name: name.trim(), values })}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={testing || saving}>
|
||||
<Wifi className="size-4" />
|
||||
{testing ? 'Ověřuji...' : 'Ověřit napojení'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => patch({ enabled: !connector.enabled })}
|
||||
disabled={saving}
|
||||
>
|
||||
{connector.enabled ? 'Vypnout' : 'Zapnout'}
|
||||
</Button>
|
||||
{!connector.isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => patch({ isDefault: true })}
|
||||
disabled={saving}
|
||||
>
|
||||
<Star className="size-4" />
|
||||
Nastavit jako výchozí
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={saving}>
|
||||
<Trash2 className="size-4" />
|
||||
Smazat
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationList({
|
||||
title,
|
||||
icon,
|
||||
operations,
|
||||
emptyLabel,
|
||||
function StatusBadge({ connector }: { connector: Connector }) {
|
||||
if (!connector.ready) return <Badge tone="warn">Chybí údaje</Badge>;
|
||||
if (connector.status === 'ok') return <Badge tone="ok">Ověřeno</Badge>;
|
||||
if (connector.status === 'error') return <Badge tone="danger">Ověření selhalo</Badge>;
|
||||
return <Badge tone="neutral">Neověřeno</Badge>;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ formulare
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
operations: ConnectorOperation[];
|
||||
emptyLabel: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{operations.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{operations.map((operation) => (
|
||||
<li key={operation.id} className="flex items-start gap-1.5 text-sm text-white/60">
|
||||
<span>{operation.name}</span>
|
||||
{/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */}
|
||||
{operation.implementation === 'script' && (
|
||||
<Terminal
|
||||
className="mt-1 size-3 shrink-0 text-ok-400"
|
||||
aria-label="Obsluhuje skript, opravdu se vykoná"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-white/70">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="mt-1 block text-xs text-white/35">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pole pristupoveho udaje.
|
||||
*
|
||||
* U tajneho pole server hodnotu nevraci, takze se ukazuje jen to, ze vyplnene
|
||||
* je. Prazdne pole hodnotu nemeni - ulozeni formulare tedy nic nepresepe.
|
||||
*/
|
||||
function CredentialInput({
|
||||
field,
|
||||
value,
|
||||
filled,
|
||||
existing,
|
||||
onChange,
|
||||
}: {
|
||||
field: ServiceCredentialField;
|
||||
value: string;
|
||||
filled: boolean;
|
||||
existing?: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const placeholder = field.secret
|
||||
? filled
|
||||
? 'Vyplněno, prázdné pole hodnotu nezmění'
|
||||
: 'Nevyplněno'
|
||||
: (existing ?? '');
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
|
||||
{field.label}
|
||||
{field.required && <span className="text-warn-400">*</span>}
|
||||
{field.secret && <Badge tone="neutral">tajné</Badge>}
|
||||
{filled && <Badge tone="ok">vyplněno</Badge>}
|
||||
</span>
|
||||
<input
|
||||
type={field.secret ? 'password' : 'text'}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-white/35">
|
||||
{field.hint ??
|
||||
`Posílá se jako ${field.target === 'header' ? 'hlavička' : 'nastavení'} ${field.name}.`}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
Connector,
|
||||
ScriptCatalog,
|
||||
ScriptDetail,
|
||||
ScriptField,
|
||||
@@ -48,20 +48,14 @@ export default function Scripts() {
|
||||
if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
|
||||
}, [items, selectedId]);
|
||||
|
||||
const connectionsById = useMemo(() => {
|
||||
const map = new Map<string, ConnectionStatus>();
|
||||
for (const connection of catalog.data?.connections ?? []) {
|
||||
map.set(connection.connectorId, connection);
|
||||
}
|
||||
return map;
|
||||
}, [catalog.data]);
|
||||
const connectorsByService = catalog.data?.connectors ?? {};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ScriptCatalog['items']>();
|
||||
for (const item of items) {
|
||||
const list = map.get(item.connectorId) ?? [];
|
||||
const list = map.get(item.serviceId) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.connectorId, list);
|
||||
map.set(item.serviceId, list);
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [items]);
|
||||
@@ -89,11 +83,11 @@ export default function Scripts() {
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
{grouped.map(([connectorId, scripts]) => (
|
||||
{grouped.map(([serviceId, scripts]) => (
|
||||
<ConnectorGroup
|
||||
key={connectorId}
|
||||
connectorId={connectorId}
|
||||
connection={connectionsById.get(connectorId)}
|
||||
key={serviceId}
|
||||
serviceId={serviceId}
|
||||
connection={connectorsByService[serviceId]}
|
||||
scripts={scripts}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
@@ -127,14 +121,14 @@ export default function Scripts() {
|
||||
// ------------------------------------------------------------------- seznam
|
||||
|
||||
function ConnectorGroup({
|
||||
connectorId,
|
||||
serviceId,
|
||||
connection,
|
||||
scripts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
connectorId: string;
|
||||
connection: ConnectionStatus | undefined;
|
||||
serviceId: string;
|
||||
connection: Connector | undefined;
|
||||
scripts: ScriptCatalog['items'];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
@@ -142,7 +136,7 @@ function ConnectorGroup({
|
||||
return (
|
||||
<div className="glass rounded-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-white">{connectorId}</p>
|
||||
<p className="font-semibold text-white">{serviceId}</p>
|
||||
{connection && (
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojeno' : 'Chybí údaje'}
|
||||
@@ -242,7 +236,7 @@ function ScriptPanel({
|
||||
<h2 className="font-semibold text-white">{detail.manifest?.name ?? detail.id}</h2>
|
||||
<p className="mt-1 font-mono text-xs text-white/35">{detail.id}.js</p>
|
||||
</div>
|
||||
<ConnectionBadge connection={detail.connection} />
|
||||
<ConnectorBadge connector={detail.connector} />
|
||||
</div>
|
||||
|
||||
{detail.manifest && (
|
||||
@@ -256,12 +250,19 @@ function ScriptPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!detail.connection.ready && (
|
||||
{detail.connector === null ? (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Napojení není nastavené, skript nepůjde spustit. Chybí:{' '}
|
||||
<code>{detail.connection.missing.join(', ')}</code>. Nastavte je jako
|
||||
proměnné aplikace v AppFactory.
|
||||
Služba {detail.serviceName} nemá v této firmě konektor, skript nepůjde
|
||||
spustit. Vytvořte ho na stránce Konektory.
|
||||
</p>
|
||||
) : (
|
||||
!detail.connector.ready && (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Konektor {detail.connector.name} nemá vyplněné všechny přístupové údaje,
|
||||
chybí <code>{detail.connector.missing.join(', ')}</code>. Doplňte je na
|
||||
stránce Konektory.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -276,7 +277,7 @@ function ScriptPanel({
|
||||
<TestPanel
|
||||
scriptId={detail.id}
|
||||
fields={detail.manifest.inputs}
|
||||
ready={detail.connection.ready}
|
||||
ready={detail.connector?.ready === true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -295,13 +296,15 @@ function ScriptPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionBadge({ connection }: { connection: ConnectionStatus }) {
|
||||
function ConnectorBadge({ connector }: { connector: Connector | null }) {
|
||||
if (!connector) return <Badge tone="warn">Bez konektoru</Badge>;
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'}
|
||||
<Badge tone={connector.ready ? 'ok' : 'warn'}>
|
||||
{connector.ready ? 'Konektor připravený' : 'Konektor bez údajů'}
|
||||
</Badge>
|
||||
<p className="mt-1 font-mono text-xs break-all text-white/30">{connection.baseUrl}</p>
|
||||
<p className="mt-1 text-xs text-white/30">{connector.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { CheckCircle2, Clock, Lock, Plug, Search, Terminal, Zap } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { brand } from '@/config/brand';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ServiceCategory,
|
||||
ServiceOperation,
|
||||
ServiceOverview,
|
||||
ServiceWithUsage,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Katalog sluzeb: co umime.
|
||||
*
|
||||
* Sluzba neni to same jako konektor. Sluzba je to, co umime; konektor je
|
||||
* napojeni teto firmy na ni vcetne jejich pristupovych udaju. Proto se stav
|
||||
* "napojeno" pocita z toho, jestli firma konektor ma, a neni to vlastnost
|
||||
* sluzby - viz documentation/12-sluzby-a-konektory.md.
|
||||
*
|
||||
* Sluzba, kterou uzivatel nema videt, se ze serveru vubec nevraci.
|
||||
*/
|
||||
|
||||
/** Stav sluzby ocima firmy. Pocita se z konektoru, necte se z katalogu. */
|
||||
type ServiceView = 'connected' | 'available' | 'planned';
|
||||
|
||||
const viewMeta: Record<ServiceView, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
available: { label: 'Můžete napojit', tone: 'neutral' },
|
||||
planned: { label: 'Na roadmapě', tone: 'warn' },
|
||||
};
|
||||
|
||||
function viewOf(service: ServiceWithUsage): ServiceView {
|
||||
if (service.status === 'planned') return 'planned';
|
||||
// Obecna sluzba konektor nepotrebuje, je pripravena rovnou.
|
||||
if (service.general || service.connectorCount > 0) return 'connected';
|
||||
return 'available';
|
||||
}
|
||||
|
||||
export default function Services() {
|
||||
usePageMeta({ title: 'Služby - portál Automia' });
|
||||
|
||||
const { data, loading, error, reload } = useApiQuery<ServiceOverview>(
|
||||
'/api/dashboard/connectors/services',
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
|
||||
const [view, setView] = useState<ServiceView | 'all'>('all');
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return items.filter((service) => {
|
||||
if (category !== 'all' && service.category !== category) return false;
|
||||
if (view !== 'all' && viewOf(service) !== view) return false;
|
||||
if (needle.length === 0) return true;
|
||||
const haystack = [
|
||||
service.name,
|
||||
service.description,
|
||||
...service.triggers.map((t) => t.name),
|
||||
...service.actions.map((a) => a.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [items, query, category, view]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
connected: items.filter((s) => viewOf(s) === 'connected').length,
|
||||
available: items.filter((s) => viewOf(s) === 'available').length,
|
||||
planned: items.filter((s) => viewOf(s) === 'planned').length,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Služby</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Co umíme napojit. Každá služba nabízí spouštěče (čím běh začne) a akce (co se
|
||||
má stát). Obecné služby fungují rovnou, u ostatních si vytvoříte{' '}
|
||||
<Link to="/dashboard/konektory" className="text-brand-300 hover:text-brand-200">
|
||||
konektor
|
||||
</Link>{' '}
|
||||
se svými přístupovými údaji.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<SummaryTile
|
||||
icon={CheckCircle2}
|
||||
value={counts.connected}
|
||||
label="připraveno k použití"
|
||||
tone="ok"
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={Plug}
|
||||
value={counts.available}
|
||||
label="můžete si napojit"
|
||||
tone="brand"
|
||||
/>
|
||||
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Hledat službu, spouštěč nebo akci"
|
||||
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
|
||||
Všechny kategorie
|
||||
</Chip>
|
||||
{categories.map((cat) => (
|
||||
<Chip
|
||||
key={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={view === 'all'} onClick={() => setView('all')}>
|
||||
Vše
|
||||
</Chip>
|
||||
{(['connected', 'available', 'planned'] as const).map((value) => (
|
||||
<Chip key={value} active={view === value} onClick={() => setView(value)}>
|
||||
{viewMeta[value].label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-white/45">
|
||||
Nic neodpovídá filtru. Chybí vám služba?{' '}
|
||||
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
|
||||
Napište nám
|
||||
</a>
|
||||
, většinu API umíme napojit přes HTTP požadavek.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filtered.map((service) => (
|
||||
<ServiceCard key={service.id} service={service} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
icon: typeof Plug;
|
||||
value: number;
|
||||
label: string;
|
||||
tone: 'ok' | 'brand' | 'warn';
|
||||
}) {
|
||||
const tones = {
|
||||
ok: 'bg-ok-500/12 text-ok-400',
|
||||
brand: 'bg-brand-500/12 text-brand-300',
|
||||
warn: 'bg-warn-500/12 text-warn-400',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className="glass flex items-center gap-4 rounded-card p-5">
|
||||
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
|
||||
<p className="text-xs text-white/45">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
|
||||
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ service }: { service: ServiceWithUsage }) {
|
||||
const Icon = serviceIcon(service.icon);
|
||||
const meta = viewMeta[viewOf(service)];
|
||||
|
||||
return (
|
||||
<article className="glass flex h-full flex-col rounded-card p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
|
||||
{service.name}
|
||||
{service.general && <Badge tone="neutral">obecná</Badge>}
|
||||
{service.visibility.mode === 'restricted' && (
|
||||
<Lock className="size-3.5 text-warn-400" aria-label="Omezená viditelnost" />
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-white/55">{service.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={meta.tone}>{meta.label}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
operations={service.triggers}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
operations={service.actions}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!service.general && (
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
|
||||
<span className="text-white/40">
|
||||
{service.connectorCount === 0
|
||||
? 'Bez konektoru službu ve stromu použít nelze.'
|
||||
: `${service.connectorCount} ${service.connectorCount === 1 ? 'konektor' : 'konektory'}`}
|
||||
</span>
|
||||
<Link
|
||||
to={`/dashboard/konektory?service=${service.id}`}
|
||||
className="text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
{service.connectorCount === 0 ? 'Vytvořit konektor' : 'Spravovat konektory'}
|
||||
</Link>
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationList({
|
||||
title,
|
||||
icon,
|
||||
operations,
|
||||
emptyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
operations: ServiceOperation[];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{operations.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{operations.map((operation) => (
|
||||
<li key={operation.id} className="flex items-start gap-1.5 text-sm text-white/60">
|
||||
<span>{operation.name}</span>
|
||||
{/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */}
|
||||
{operation.implementation === 'script' && (
|
||||
<Terminal
|
||||
className="mt-1 size-3 shrink-0 text-ok-400"
|
||||
aria-label="Obsluhuje skript, opravdu se vykoná"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { formatDateTime } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ConnectorCatalog,
|
||||
ServiceCatalog,
|
||||
PeopleResponse,
|
||||
TicketDetail as Detail,
|
||||
TicketStatus,
|
||||
@@ -48,7 +48,7 @@ export default function TicketDetail() {
|
||||
refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
|
||||
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
|
||||
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -168,7 +168,7 @@ export default function TicketDetail() {
|
||||
|
||||
<TicketTrace
|
||||
entries={ticket.data.trace}
|
||||
connectors={catalog.data?.items ?? []}
|
||||
services={catalog.data?.items ?? []}
|
||||
/>
|
||||
|
||||
<form
|
||||
|
||||
+101
-33
@@ -82,7 +82,7 @@ export interface TicketTraceEntry {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
connectorId: string | null;
|
||||
serviceId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
@@ -158,20 +158,38 @@ export interface Automation {
|
||||
|
||||
// ----------------------------------------------------------------- konektory
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'servicedesk'
|
||||
export type ServiceCategory =
|
||||
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
|
||||
| 'obecne'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
| 'nastroje';
|
||||
| 'ai';
|
||||
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
/** available = sluzbu umime, planned = je na roadmape. */
|
||||
export type ServiceStatus = 'available' | 'planned';
|
||||
|
||||
export interface ConnectorOperation {
|
||||
export interface ServiceVisibility {
|
||||
mode: 'everyone' | 'restricted' | 'admin';
|
||||
tenantIds: string[];
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/** Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru. */
|
||||
export interface ServiceCredentialField {
|
||||
id: string;
|
||||
label: string;
|
||||
target: 'header' | 'config';
|
||||
name: string;
|
||||
required: boolean;
|
||||
/** true = hodnota se ze serveru nikdy nevraci. */
|
||||
secret: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ServiceOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -211,21 +229,76 @@ export interface OperationField {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ConnectorCategory;
|
||||
category: ServiceCategory;
|
||||
description: string;
|
||||
/** Klic ikony - mapuje se v lib/connectorIcons.ts */
|
||||
/** Klic ikony - mapuje se v lib/serviceIcons.ts */
|
||||
icon: string;
|
||||
status: ConnectorStatus;
|
||||
triggers: ConnectorOperation[];
|
||||
actions: ConnectorOperation[];
|
||||
status: ServiceStatus;
|
||||
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
|
||||
general: boolean;
|
||||
appId: string | null;
|
||||
visibility: ServiceVisibility;
|
||||
credentials: ServiceCredentialField[];
|
||||
verifyPath?: string;
|
||||
triggers: ServiceOperation[];
|
||||
actions: ServiceOperation[];
|
||||
}
|
||||
|
||||
export interface ConnectorCatalog {
|
||||
categories: Array<{ id: ConnectorCategory; label: string }>;
|
||||
items: Connector[];
|
||||
/** Sluzba ocima jedne firmy, tedy uz vcetne toho, co ma napojene. */
|
||||
export interface ServiceWithUsage extends Service {
|
||||
baseUrl: string;
|
||||
/** Kolik konektoru na ni firma ma. 0 = muze si napojit. */
|
||||
connectorCount: number;
|
||||
/** Kolik akci uz opravdu neco dela, tedy ma skript. */
|
||||
implementedActions: number;
|
||||
}
|
||||
|
||||
export interface ServiceOverview {
|
||||
categories: Array<{ id: ServiceCategory; label: string }>;
|
||||
items: ServiceWithUsage[];
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konektor = napojeni firmy na jednu sluzbu.
|
||||
* Hodnoty pristupovych udaju server nikdy nevraci, jen `filled` a `missing`.
|
||||
*/
|
||||
export interface Connector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
enabled: boolean;
|
||||
status: 'untested' | 'ok' | 'error';
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** ID poli, ktera jsou vyplnena. */
|
||||
filled: string[];
|
||||
/** ID povinnych poli, ktera chybi. */
|
||||
missing: string[];
|
||||
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
|
||||
config: Record<string, string>;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectorTestResult {
|
||||
ok: boolean;
|
||||
/** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */
|
||||
checked: string;
|
||||
status?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServiceCatalog {
|
||||
categories: Array<{ id: ServiceCategory; label: string }>;
|
||||
items: Service[];
|
||||
/** Ktere operatory server povoli pro ktery typ parametru. */
|
||||
operatorsByType: Record<FieldType, ConditionOperator[]>;
|
||||
/** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */
|
||||
@@ -262,7 +335,7 @@ export interface TriggerField {
|
||||
// ----------------------------------------------------------- strom automatizace
|
||||
|
||||
export interface FlowTrigger {
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
fields: TriggerField[];
|
||||
/** Generuje vyhradne server, klient ho jen zobrazuje. */
|
||||
@@ -274,7 +347,7 @@ export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */
|
||||
inputs?: Record<string, string>;
|
||||
@@ -380,7 +453,8 @@ export interface ScriptField {
|
||||
|
||||
export interface ScriptManifest {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
operationId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -396,32 +470,26 @@ export interface ScriptProblem {
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
}
|
||||
|
||||
/** Stav napojeni. Hodnoty pristupovych udaju se ze serveru nikdy nevraci. */
|
||||
export interface ConnectionStatus {
|
||||
connectorId: string;
|
||||
baseUrl: string;
|
||||
ready: boolean;
|
||||
/** Jmena chybejicich environment variables. */
|
||||
missing: string[];
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
export interface ScriptCatalog {
|
||||
items: ScriptManifest[];
|
||||
problems: ScriptProblem[];
|
||||
connections: ConnectionStatus[];
|
||||
/** Vychozi konektor firmy podle ID sluzby. Chybi = sluzba neni napojena. */
|
||||
connectors: Record<string, Connector>;
|
||||
tenantId: string | null;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ScriptDetail {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
serviceName: string;
|
||||
/** null = soubor je rozbity, manifest se nepodarilo precist. */
|
||||
manifest: ScriptManifest | null;
|
||||
code: string;
|
||||
problem: ScriptProblem | null;
|
||||
connection: ConnectionStatus;
|
||||
/** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */
|
||||
connector: Connector | null;
|
||||
}
|
||||
|
||||
export type ScriptErrorKind =
|
||||
|
||||
Reference in New Issue
Block a user