@greatapps/greatagents-ui 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greatapps/greatagents-ui",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Shared agents UI components for Great platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -12,6 +12,7 @@ import type {
12
12
  Tool,
13
13
  ToolCredential,
14
14
  } from "../types";
15
+ import type { AgentCapability, AgentCapabilitiesPayload, CapabilitiesResponse } from "../types/capabilities";
15
16
 
16
17
  export interface GagentsClientConfig {
17
18
  baseUrl: string;
@@ -248,6 +249,19 @@ export function createGagentsClient(config: GagentsClientConfig) {
248
249
  undefined,
249
250
  { provider },
250
251
  ),
252
+
253
+ // --- Capabilities ---
254
+ getCapabilities: (idAccount: number) =>
255
+ request<CapabilitiesResponse>("GET", idAccount, "capabilities"),
256
+
257
+ getAgentCapabilities: (idAccount: number, idAgent: number) =>
258
+ request<AgentCapability[]>("GET", idAccount, `agents/${idAgent}/capabilities`),
259
+
260
+ updateAgentCapabilities: (
261
+ idAccount: number,
262
+ idAgent: number,
263
+ body: AgentCapabilitiesPayload,
264
+ ) => request<AgentCapability[]>("PUT", idAccount, `agents/${idAgent}/capabilities`, body),
251
265
  };
252
266
  }
253
267
 
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import { useState } from "react";
4
+ import type { GagentsHookConfig } from "../../hooks/types";
5
+ import { useToolCredentials } from "../../hooks";
6
+ import { ToolsTable } from "../tools/tools-table";
7
+ import { ToolCredentialsForm } from "../tools/tool-credentials-form";
8
+ import { ToolFormDialog } from "../tools/tool-form-dialog";
9
+ import type { Tool } from "../../types";
10
+ import { Info } from "lucide-react";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Props
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface AdvancedTabProps {
17
+ config: GagentsHookConfig;
18
+ agentId: number;
19
+ gagentsApiUrl: string;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Component
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function AdvancedTab({ config, agentId, gagentsApiUrl }: AdvancedTabProps) {
27
+ const { data: credentialsData, isLoading: isLoadingCredentials } =
28
+ useToolCredentials(config);
29
+ const credentials = credentialsData?.data ?? [];
30
+
31
+ const [editingTool, setEditingTool] = useState<Tool | null>(null);
32
+ const [showToolForm, setShowToolForm] = useState(false);
33
+
34
+ function handleEditTool(tool: Tool) {
35
+ setEditingTool(tool);
36
+ setShowToolForm(true);
37
+ }
38
+
39
+ function handleToolFormOpenChange(open: boolean) {
40
+ setShowToolForm(open);
41
+ if (!open) setEditingTool(null);
42
+ }
43
+
44
+ return (
45
+ <div className="space-y-8">
46
+ {/* Info banner */}
47
+ <div className="flex items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
48
+ <Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
49
+ <p className="text-sm text-blue-800 dark:text-blue-300">
50
+ Use as abas <strong>Capacidades</strong> e <strong>Integrações</strong> para
51
+ configuração simplificada. Esta aba oferece controlo manual avançado sobre
52
+ ferramentas e credenciais.
53
+ </p>
54
+ </div>
55
+
56
+ {/* Ferramentas section */}
57
+ <section className="space-y-3">
58
+ <h3 className="text-sm font-medium">Ferramentas</h3>
59
+ <ToolsTable onEdit={handleEditTool} config={config} />
60
+ </section>
61
+
62
+ {/* Credenciais section */}
63
+ <section className="space-y-3">
64
+ <h3 className="text-sm font-medium">Credenciais</h3>
65
+ <ToolCredentialsForm
66
+ credentials={credentials}
67
+ isLoading={isLoadingCredentials}
68
+ config={config}
69
+ gagentsApiUrl={gagentsApiUrl}
70
+ />
71
+ </section>
72
+
73
+ {/* Tool edit dialog */}
74
+ <ToolFormDialog
75
+ open={showToolForm}
76
+ onOpenChange={handleToolFormOpenChange}
77
+ tool={editingTool ?? undefined}
78
+ config={config}
79
+ />
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,475 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
4
+ import type { GagentsHookConfig } from "../../hooks/types";
5
+ import {
6
+ useCapabilities,
7
+ useAgentCapabilities,
8
+ useUpdateAgentCapabilities,
9
+ } from "../../hooks";
10
+ import type {
11
+ CapabilityCategory,
12
+ CapabilityModule,
13
+ AgentCapability,
14
+ AgentCapabilitiesPayload,
15
+ } from "../../types/capabilities";
16
+ import {
17
+ Accordion,
18
+ AccordionItem,
19
+ AccordionTrigger,
20
+ AccordionContent,
21
+ Switch,
22
+ Checkbox,
23
+ Badge,
24
+ Button,
25
+ Skeleton,
26
+ } from "@greatapps/greatauth-ui/ui";
27
+ import {
28
+ Calendar,
29
+ Users,
30
+ Settings,
31
+ HeartHandshake,
32
+ Package,
33
+ ChevronDown,
34
+ } from "lucide-react";
35
+ import { toast } from "sonner";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Operation label mapping (pt-BR)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const OPERATION_LABELS: Record<string, string> = {
42
+ list: "Listar",
43
+ view: "Visualizar",
44
+ create: "Criar",
45
+ update: "Atualizar",
46
+ };
47
+
48
+ function getOperationLabel(slug: string): string {
49
+ return OPERATION_LABELS[slug] ?? slug;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Category icon mapping
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const CATEGORY_ICONS: Record<string, React.ElementType> = {
57
+ agenda: Calendar,
58
+ cadastros: Users,
59
+ infraestrutura: Settings,
60
+ relacionamentos: HeartHandshake,
61
+ };
62
+
63
+ function getCategoryIcon(slug: string): React.ElementType {
64
+ return CATEGORY_ICONS[slug] ?? Package;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Internal state helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Map from module slug -> set of enabled operation slugs */
72
+ type CapabilityState = Map<string, Set<string>>;
73
+
74
+ function buildStateFromAgent(agentCaps: AgentCapability[]): CapabilityState {
75
+ const state: CapabilityState = new Map();
76
+ for (const cap of agentCaps) {
77
+ state.set(cap.module, new Set(cap.operations));
78
+ }
79
+ return state;
80
+ }
81
+
82
+ function stateToPayload(state: CapabilityState): AgentCapabilitiesPayload {
83
+ const capabilities: AgentCapability[] = [];
84
+ state.forEach((ops, mod) => {
85
+ if (ops.size > 0) {
86
+ capabilities.push({ module: mod, operations: Array.from(ops) });
87
+ }
88
+ });
89
+ return { capabilities };
90
+ }
91
+
92
+ function cloneState(state: CapabilityState): CapabilityState {
93
+ const next: CapabilityState = new Map();
94
+ state.forEach((ops, mod) => next.set(mod, new Set(ops)));
95
+ return next;
96
+ }
97
+
98
+ function statesEqual(a: CapabilityState, b: CapabilityState): boolean {
99
+ if (a.size !== b.size) return false;
100
+ for (const [mod, opsA] of a) {
101
+ const opsB = b.get(mod);
102
+ if (!opsB || opsA.size !== opsB.size) return false;
103
+ for (const op of opsA) {
104
+ if (!opsB.has(op)) return false;
105
+ }
106
+ }
107
+ return true;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Props
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export interface CapabilitiesTabProps {
115
+ config: GagentsHookConfig;
116
+ agentId: number;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Component
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export function CapabilitiesTab({ config, agentId }: CapabilitiesTabProps) {
124
+ const { data: registry, isLoading: isLoadingRegistry } = useCapabilities(config);
125
+ const { data: agentCaps, isLoading: isLoadingAgent } = useAgentCapabilities(config, agentId);
126
+ const updateMutation = useUpdateAgentCapabilities(config);
127
+
128
+ // Local state for optimistic updates
129
+ const [localState, setLocalState] = useState<CapabilityState>(new Map());
130
+ const [serverState, setServerState] = useState<CapabilityState>(new Map());
131
+ const [initialized, setInitialized] = useState(false);
132
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+
134
+ // Sync server data into local state on first load / refetch
135
+ useEffect(() => {
136
+ if (agentCaps && !initialized) {
137
+ const state = buildStateFromAgent(agentCaps);
138
+ setLocalState(state);
139
+ setServerState(cloneState(state));
140
+ setInitialized(true);
141
+ }
142
+ }, [agentCaps, initialized]);
143
+
144
+ // Reset initialized when agentId changes
145
+ useEffect(() => {
146
+ setInitialized(false);
147
+ }, [agentId]);
148
+
149
+ const hasChanges = useMemo(
150
+ () => initialized && !statesEqual(localState, serverState),
151
+ [localState, serverState, initialized],
152
+ );
153
+
154
+ // ------ Debounced save ------
155
+ const scheduleSave = useCallback(
156
+ (nextState: CapabilityState) => {
157
+ if (debounceRef.current) clearTimeout(debounceRef.current);
158
+ debounceRef.current = setTimeout(() => {
159
+ const payload = stateToPayload(nextState);
160
+ updateMutation.mutate(
161
+ { agentId, payload },
162
+ {
163
+ onSuccess: () => {
164
+ setServerState(cloneState(nextState));
165
+ toast.success("Capacidades salvas");
166
+ },
167
+ onError: () => {
168
+ // Rollback to server state
169
+ setLocalState(cloneState(serverState));
170
+ toast.error("Erro ao salvar capacidades");
171
+ },
172
+ },
173
+ );
174
+ }, 500);
175
+ },
176
+ [agentId, updateMutation, serverState],
177
+ );
178
+
179
+ // ------ State mutation helpers ------
180
+ const updateState = useCallback(
181
+ (updater: (prev: CapabilityState) => CapabilityState) => {
182
+ setLocalState((prev) => {
183
+ const next = updater(prev);
184
+ scheduleSave(next);
185
+ return next;
186
+ });
187
+ },
188
+ [scheduleSave],
189
+ );
190
+
191
+ const toggleModule = useCallback(
192
+ (mod: CapabilityModule, enabled: boolean) => {
193
+ updateState((prev) => {
194
+ const next = cloneState(prev);
195
+ if (enabled) {
196
+ next.set(mod.slug, new Set(mod.operations));
197
+ } else {
198
+ next.delete(mod.slug);
199
+ }
200
+ return next;
201
+ });
202
+ },
203
+ [updateState],
204
+ );
205
+
206
+ const toggleOperation = useCallback(
207
+ (mod: CapabilityModule, opSlug: string, enabled: boolean) => {
208
+ updateState((prev) => {
209
+ const next = cloneState(prev);
210
+ const ops = new Set(next.get(mod.slug) ?? []);
211
+ if (enabled) {
212
+ ops.add(opSlug);
213
+ } else {
214
+ ops.delete(opSlug);
215
+ }
216
+ if (ops.size > 0) {
217
+ next.set(mod.slug, ops);
218
+ } else {
219
+ next.delete(mod.slug);
220
+ }
221
+ return next;
222
+ });
223
+ },
224
+ [updateState],
225
+ );
226
+
227
+ const enableAll = useCallback(() => {
228
+ if (!registry) return;
229
+ updateState(() => {
230
+ const next: CapabilityState = new Map();
231
+ for (const cat of registry.categories) {
232
+ for (const mod of cat.modules) {
233
+ next.set(mod.slug, new Set(mod.operations));
234
+ }
235
+ }
236
+ return next;
237
+ });
238
+ }, [registry, updateState]);
239
+
240
+ const disableAll = useCallback(() => {
241
+ updateState(() => new Map());
242
+ }, [updateState]);
243
+
244
+ const discard = useCallback(() => {
245
+ if (debounceRef.current) clearTimeout(debounceRef.current);
246
+ setLocalState(cloneState(serverState));
247
+ }, [serverState]);
248
+
249
+ const saveNow = useCallback(() => {
250
+ if (debounceRef.current) clearTimeout(debounceRef.current);
251
+ const payload = stateToPayload(localState);
252
+ updateMutation.mutate(
253
+ { agentId, payload },
254
+ {
255
+ onSuccess: () => {
256
+ setServerState(cloneState(localState));
257
+ toast.success("Capacidades salvas");
258
+ },
259
+ onError: () => {
260
+ setLocalState(cloneState(serverState));
261
+ toast.error("Erro ao salvar capacidades");
262
+ },
263
+ },
264
+ );
265
+ }, [agentId, localState, serverState, updateMutation]);
266
+
267
+ // ------ Counting helpers ------
268
+ function countActiveModules(cat: CapabilityCategory): number {
269
+ return cat.modules.filter((m) => (localState.get(m.slug)?.size ?? 0) > 0).length;
270
+ }
271
+
272
+ // ------ Loading state ------
273
+ if (isLoadingRegistry || isLoadingAgent) {
274
+ return (
275
+ <div className="space-y-3">
276
+ {[1, 2, 3, 4].map((i) => (
277
+ <Skeleton key={i} className="h-14 w-full" />
278
+ ))}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ // ------ Empty state ------
284
+ if (!registry || !registry.categories.length) {
285
+ return (
286
+ <div className="flex flex-col items-center justify-center py-12 text-center">
287
+ <Package className="h-12 w-12 text-muted-foreground mb-3" />
288
+ <h3 className="text-base font-medium">Nenhuma capacidade disponível</h3>
289
+ <p className="text-sm text-muted-foreground mt-1 max-w-sm">
290
+ Este produto ainda não possui capacidades registadas. As capacidades serão
291
+ adicionadas automaticamente quando o produto for configurado.
292
+ </p>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ // ------ Render ------
298
+ return (
299
+ <div className="space-y-4">
300
+ {/* Header with global actions */}
301
+ <div className="flex items-center justify-between">
302
+ <div>
303
+ <h3 className="text-sm font-medium">Capacidades do agente</h3>
304
+ <p className="text-xs text-muted-foreground mt-0.5">
305
+ Ative ou desative módulos e operações disponíveis para este agente.
306
+ </p>
307
+ </div>
308
+ <div className="flex items-center gap-2">
309
+ <Button variant="outline" size="sm" onClick={enableAll}>
310
+ Ativar tudo
311
+ </Button>
312
+ <Button variant="outline" size="sm" onClick={disableAll}>
313
+ Desativar tudo
314
+ </Button>
315
+ </div>
316
+ </div>
317
+
318
+ {/* Category accordions */}
319
+ <Accordion type="multiple" className="space-y-2">
320
+ {registry.categories.map((cat) => {
321
+ const Icon = getCategoryIcon(cat.slug);
322
+ const activeCount = countActiveModules(cat);
323
+ const totalModules = cat.modules.length;
324
+
325
+ return (
326
+ <AccordionItem
327
+ key={cat.slug}
328
+ value={cat.slug}
329
+ className="border rounded-lg px-4"
330
+ >
331
+ <AccordionTrigger className="hover:no-underline py-3">
332
+ <div className="flex items-center gap-3 flex-1">
333
+ <Icon className="h-4 w-4 text-muted-foreground" />
334
+ <span className="font-medium text-sm">{cat.label}</span>
335
+ <Badge variant="secondary" className="text-xs">
336
+ {activeCount} de {totalModules} módulos ativos
337
+ </Badge>
338
+ </div>
339
+ </AccordionTrigger>
340
+ <AccordionContent className="pb-3">
341
+ <div className="space-y-1">
342
+ {cat.modules.map((mod) => {
343
+ const enabledOps = localState.get(mod.slug);
344
+ const isModuleOn = (enabledOps?.size ?? 0) > 0;
345
+ const allOpsEnabled =
346
+ enabledOps?.size === mod.operations.length;
347
+
348
+ return (
349
+ <ModuleRow
350
+ key={mod.slug}
351
+ module={mod}
352
+ isOn={isModuleOn}
353
+ allOpsEnabled={allOpsEnabled}
354
+ enabledOps={enabledOps ?? new Set()}
355
+ onToggleModule={(on) => toggleModule(mod, on)}
356
+ onToggleOperation={(op, on) =>
357
+ toggleOperation(mod, op, on)
358
+ }
359
+ />
360
+ );
361
+ })}
362
+ </div>
363
+ </AccordionContent>
364
+ </AccordionItem>
365
+ );
366
+ })}
367
+ </Accordion>
368
+
369
+ {/* Save bar */}
370
+ {hasChanges && (
371
+ <div className="sticky bottom-0 bg-background border-t py-3 px-4 -mx-4 flex items-center justify-between">
372
+ <span className="text-sm text-muted-foreground">
373
+ Você tem alterações não salvas.
374
+ </span>
375
+ <div className="flex items-center gap-2">
376
+ <Button variant="outline" size="sm" onClick={discard}>
377
+ Descartar
378
+ </Button>
379
+ <Button
380
+ size="sm"
381
+ onClick={saveNow}
382
+ disabled={updateMutation.isPending}
383
+ >
384
+ {updateMutation.isPending ? "Salvando..." : "Salvar"}
385
+ </Button>
386
+ </div>
387
+ </div>
388
+ )}
389
+ </div>
390
+ );
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // ModuleRow sub-component
395
+ // ---------------------------------------------------------------------------
396
+
397
+ interface ModuleRowProps {
398
+ module: CapabilityModule;
399
+ isOn: boolean;
400
+ allOpsEnabled: boolean;
401
+ enabledOps: Set<string>;
402
+ onToggleModule: (on: boolean) => void;
403
+ onToggleOperation: (op: string, on: boolean) => void;
404
+ }
405
+
406
+ function ModuleRow({
407
+ module: mod,
408
+ isOn,
409
+ enabledOps,
410
+ onToggleModule,
411
+ onToggleOperation,
412
+ }: ModuleRowProps) {
413
+ const [expanded, setExpanded] = useState(false);
414
+
415
+ return (
416
+ <div className="rounded-md border px-3 py-2">
417
+ <div className="flex items-center justify-between">
418
+ <button
419
+ type="button"
420
+ className="flex items-center gap-2 flex-1 text-left"
421
+ onClick={() => setExpanded(!expanded)}
422
+ aria-expanded={expanded}
423
+ aria-label={`Expandir ${mod.label}`}
424
+ >
425
+ <ChevronDown
426
+ className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${
427
+ expanded ? "rotate-0" : "-rotate-90"
428
+ }`}
429
+ />
430
+ <span className="text-sm font-medium">{mod.label}</span>
431
+ {mod.description && (
432
+ <span className="text-xs text-muted-foreground hidden sm:inline">
433
+ — {mod.description}
434
+ </span>
435
+ )}
436
+ {isOn && (
437
+ <Badge variant="secondary" className="text-xs ml-1">
438
+ {enabledOps.size}/{mod.operations.length}
439
+ </Badge>
440
+ )}
441
+ </button>
442
+ <Switch
443
+ checked={isOn}
444
+ onCheckedChange={onToggleModule}
445
+ aria-label={`Ativar módulo ${mod.label}`}
446
+ />
447
+ </div>
448
+
449
+ {expanded && (
450
+ <div className="mt-2 ml-6 flex flex-wrap gap-x-5 gap-y-1.5 pb-1">
451
+ {mod.operations.map((op) => {
452
+ const checked = enabledOps.has(op);
453
+ return (
454
+ <label
455
+ key={op}
456
+ className="flex items-center gap-1.5 text-sm cursor-pointer"
457
+ >
458
+ <Checkbox
459
+ checked={checked}
460
+ onCheckedChange={(val) =>
461
+ onToggleOperation(op, val === true)
462
+ }
463
+ aria-label={`${getOperationLabel(op)} em ${mod.label}`}
464
+ />
465
+ <span className={checked ? "" : "text-muted-foreground"}>
466
+ {getOperationLabel(op)}
467
+ </span>
468
+ </label>
469
+ );
470
+ })}
471
+ </div>
472
+ )}
473
+ </div>
474
+ );
475
+ }