@decido/plugin-planta-erp 1.0.0

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.
@@ -0,0 +1,133 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { kernel } from '@decido/kernel-bridge';
3
+ import { ReactFlow, Background, BackgroundVariant, Controls } from '@xyflow/react';
4
+ import { nodeTypes, edgeTypes } from '@decido/canvas-core';
5
+ import '@xyflow/react/dist/style.css';
6
+
7
+ // Pre-posiciones estáticas para los agentes más comunes en el clúster
8
+ const KNOWN_NODE_POSITIONS: Record<string, { x: number, y: number }> = {
9
+ 'user-client': { x: 50, y: 250 },
10
+ 'dev-master': { x: 400, y: 100 },
11
+ 'ui-morpher': { x: 400, y: 400 },
12
+ 'system-orchestrator': { x: 750, y: 250 }
13
+ };
14
+
15
+ export const SwarmCanvas: React.FC = () => {
16
+ const [nodes, setNodes] = useState<any[]>([]);
17
+ const [edges, setEdges] = useState<any[]>([]);
18
+
19
+ const [activeAgents, setActiveAgents] = useState<Set<string>>(new Set(['user-client', 'dev-master']));
20
+
21
+ useEffect(() => {
22
+ // Al montar, registrar nodes base
23
+ const baseNodes = Array.from(activeAgents).map((id, i) => ({
24
+ id,
25
+ type: 'moduleNode',
26
+ position: KNOWN_NODE_POSITIONS[id] || { x: 200 + (i * 100), y: 200 + (Math.random() * 100) },
27
+ data: {
28
+ label: id === 'user-client' ? 'Web Client' : id,
29
+ status: 'active',
30
+ description: id === 'dev-master' ? 'Rust Orchestrator' : 'Enjambre'
31
+ }
32
+ }));
33
+ setNodes(baseNodes);
34
+ }, []);
35
+
36
+ // Escuchar el Kernel Universal Córtex para Eventos del Enjambre
37
+ useEffect(() => {
38
+ const unsubscribe = kernel.onEvent((evt: any) => {
39
+ if (!evt || !evt.event_type) return;
40
+
41
+ // Detectamos si es un mensaje de chat o un Action command
42
+ if (evt.event_type === 'MessageSent' || evt.event_type === 'AgentCommand') {
43
+ const messageData = evt.data || {};
44
+ const from = evt.agent_id || messageData.sender || 'unknown';
45
+ const to = messageData.target || 'broadcast';
46
+ const intent = messageData.intent || evt.event_type;
47
+
48
+ // Si descubrimos un agente nuevo, lo agreamos al canvas
49
+ setActiveAgents(prev => {
50
+ const next = new Set(prev);
51
+ let changed = false;
52
+ if (!next.has(from)) { next.add(from); changed = true; }
53
+ if (to !== 'broadcast' && !next.has(to)) { next.add(to); changed = true; }
54
+
55
+ if (changed) {
56
+ // Actualizar Nodos
57
+ const allAgents = Array.from(next);
58
+ setNodes(allAgents.map((id) => ({
59
+ id,
60
+ type: 'moduleNode',
61
+ position: KNOWN_NODE_POSITIONS[id] || { x: 300 + (Math.random() * 300), y: 150 + (Math.random() * 300) },
62
+ data: { label: id, status: 'active' }
63
+ })));
64
+ }
65
+ return next;
66
+ });
67
+
68
+ // Crear Edge Animado (Sonda Visual)
69
+ const edgeId = `edge-${Date.now()}-${Math.random()}`;
70
+ const newEdge = {
71
+ id: edgeId,
72
+ source: from,
73
+ target: to === 'broadcast' ? 'dev-master' : to, // Fallback visual
74
+ type: 'animatedEdge',
75
+ animated: true,
76
+ data: { label: intent }
77
+ };
78
+
79
+ setEdges(prev => [...prev, newEdge]);
80
+
81
+ // Remover el edge después de 4 segundos para no saturar el canvas (efecto "pulso")
82
+ setTimeout(() => {
83
+ setEdges(prev => prev.filter(e => e.id !== edgeId));
84
+ }, 4000);
85
+ }
86
+ });
87
+
88
+ // También escuchamos nuestro propio evento sintético (como fallback a eventos en la memoria local del navegador)
89
+ const handleSynthetic = (_e: any) => {
90
+ // Este fallback es solo por si kernel.onEvent falla en la configuración hibrida.
91
+ // Pero con el Proxy Relay de Node esto debería llegar nativamente por redis stream.
92
+ };
93
+
94
+ window.addEventListener('kernel-synthetic-message', handleSynthetic);
95
+
96
+ return () => {
97
+ unsubscribe();
98
+ window.removeEventListener('kernel-synthetic-message', handleSynthetic);
99
+ };
100
+ }, []);
101
+
102
+ return (
103
+ <div className="w-full h-full bg-[#0a0a0a] rounded-xl overflow-hidden border border-white/10 relative">
104
+ <div className="absolute top-4 left-4 z-10 bg-black/60 backdrop-blur-md px-4 py-2 rounded-lg border border-primary/20">
105
+ <h3 className="text-white font-bold flex items-center gap-2">
106
+ <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
107
+ Swarm Flow Probes
108
+ </h3>
109
+ <p className="text-xs text-gray-400 mt-1">Interceptando Redis/RPC Bus</p>
110
+ </div>
111
+ <ReactFlow
112
+ nodes={nodes}
113
+ edges={edges}
114
+ onNodesChange={(changes: any) => {
115
+ // Mapear cambios localmente si se arrastran interactuando
116
+ setNodes(nds => nds.map(n => {
117
+ const change = changes.find((c: any) => c.id === n.id);
118
+ if (change && change.type === 'position' && change.position) {
119
+ return { ...n, position: change.position };
120
+ }
121
+ return n;
122
+ }));
123
+ }}
124
+ nodeTypes={nodeTypes}
125
+ edgeTypes={edgeTypes as any}
126
+ fitView
127
+ >
128
+ <Background variant={BackgroundVariant.Dots} color="rgba(0, 255, 136, 0.15)" />
129
+ <Controls />
130
+ </ReactFlow>
131
+ </div>
132
+ );
133
+ };
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { PhysicalCanvas } from './components/DigitalTwin/PhysicalCanvas';
4
+ import './index.css';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <PhysicalCanvas />
9
+ </React.StrictMode>
10
+ );
package/src/index.css ADDED
@@ -0,0 +1,3 @@
1
+ @import "tailwindcss";
2
+ @import "@decido/ui-kit/src/tokens.css";
3
+ @import "@decido/ui-kit/src/primitives.css";
@@ -0,0 +1,39 @@
1
+ import { MachineType } from '../types';
2
+
3
+ /**
4
+ * Agente: SLA Sentinel
5
+ * Regla: < 5 láminas = 24h, >= 5 láminas = 72h
6
+ */
7
+ export const calculateSLA = (sheets: number): number => {
8
+ return sheets < 5 ? 24 : 72;
9
+ };
10
+
11
+ /**
12
+ * Agente: Router AI
13
+ * Regla: Asignación de máquinas según material y complejidad
14
+ */
15
+ export const assignMachine = (material: string, sheets: number): MachineType => {
16
+ const mat = material.toLowerCase();
17
+
18
+ if (mat.includes('perforacion') || mat.includes('diseño') || mat.includes('cnc')) {
19
+ return 'HOMAG'; // CNC para cosas complejas
20
+ }
21
+
22
+ if (sheets > 10 || mat.includes('grueso') || mat.includes('18mm')) {
23
+ return 'GABBIANI'; // Trabajo pesado
24
+ }
25
+
26
+ return 'SIGMA'; // Corte rápido estándar
27
+ };
28
+
29
+ /**
30
+ * Agente: Phone Doctor
31
+ * Regla: Asegurar formato +57 para WhatsApp
32
+ */
33
+ export const sanitizePhone = (phone: string): string => {
34
+ const digits = phone.replace(/\D/g, '');
35
+ if (!digits.startsWith('57')) {
36
+ return `+57 ${digits}`;
37
+ }
38
+ return `+${digits}`;
39
+ };
@@ -0,0 +1,45 @@
1
+ import { MadefrontOrder, OrderStatus } from '../types';
2
+
3
+ interface TransitionResult {
4
+ allowed: boolean;
5
+ reason?: string;
6
+ }
7
+
8
+ // Mapa de transiciones permitidas (Máquina de Estados)
9
+ const ALLOWED_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
10
+ 'DISEÑO': ['OPTIMIZACION', 'CAJA'], // Puede saltar a caja si no requiere despiece
11
+ 'OPTIMIZACION': ['CAJA'],
12
+ 'CAJA': ['PRODUCCION'],
13
+ 'PRODUCCION': ['DESPACHO'],
14
+ 'DESPACHO': ['ENTREGADO'],
15
+ 'ENTREGADO': []
16
+ };
17
+
18
+ export const canTransition = (order: MadefrontOrder, targetStatus: OrderStatus): TransitionResult => {
19
+ const currentStatus = order.status;
20
+
21
+ // 1. Validar que la ruta exista en el grafo
22
+ const validNextStates = ALLOWED_TRANSITIONS[currentStatus] || [];
23
+ if (!validNextStates.includes(targetStatus)) {
24
+ return {
25
+ allowed: false,
26
+ reason: `Flujo inválido: No se puede pasar de ${currentStatus} a ${targetStatus}.`
27
+ };
28
+ }
29
+
30
+ // 2. Reglas Hardcore de Negocio (Las que nos dio Ana)
31
+ if (targetStatus === 'PRODUCCION') {
32
+ if (!order.isPaid) {
33
+ return { allowed: false, reason: 'Bloqueo de Caja: El pedido debe estar pagado para entrar a Planta.' };
34
+ }
35
+ }
36
+
37
+ if (targetStatus === 'DESPACHO') {
38
+ // En el futuro: validar que la máquina (Sigma/Homag) haya emitido el evento de fin
39
+ if (order.status !== 'PRODUCCION') {
40
+ return { allowed: false, reason: 'El pedido debe salir de producción antes de despacharse.' };
41
+ }
42
+ }
43
+
44
+ return { allowed: true };
45
+ };
@@ -0,0 +1,66 @@
1
+ import { create } from 'zustand';
2
+
3
+ export type MachineState = 'idle' | 'processing' | 'error';
4
+ export type ObjectType = 'machine' | 'order' | 'operator';
5
+
6
+ export interface SpatialNode {
7
+ id: string;
8
+ type: ObjectType;
9
+ position: [number, number, number]; // [x, y, z]
10
+ rotation?: [number, number, number];
11
+ state?: string;
12
+ metadata?: Record<string, any>;
13
+ }
14
+
15
+ interface DigitalTwinState {
16
+ nodes: Record<string, SpatialNode>;
17
+ addNode: (node: SpatialNode) => void;
18
+ updateNodePosition: (id: string, position: [number, number, number], rotation?: [number, number, number]) => void;
19
+ updateNodeState: (id: string, state: string, metadata?: any) => void;
20
+ removeNode: (id: string) => void;
21
+ }
22
+
23
+ export const useDigitalTwinStore = create<DigitalTwinState>((set) => ({
24
+ nodes: {
25
+ // Mock default physical layout: Workstations
26
+ 'corte-1': { id: 'corte-1', type: 'machine', position: [-4, 0, -2], state: 'idle', metadata: { name: 'Sierra Escuadradora' } },
27
+ 'corte-2': { id: 'corte-2', type: 'machine', position: [4, 0, -2], state: 'processing', metadata: { name: 'CNC Router' } },
28
+ 'ensamble-1': { id: 'ensamble-1', type: 'machine', position: [0, 0, 4], state: 'idle', metadata: { name: 'Zona Ensamble' } },
29
+ },
30
+
31
+ addNode: (node) => set((state) => ({
32
+ nodes: { ...state.nodes, [node.id]: node }
33
+ })),
34
+
35
+ updateNodePosition: (id, position, rotation) => set((state) => {
36
+ const node = state.nodes[id];
37
+ if (!node) return state;
38
+ return {
39
+ nodes: {
40
+ ...state.nodes,
41
+ [id]: { ...node, position, rotation: rotation || node.rotation }
42
+ }
43
+ };
44
+ }),
45
+
46
+ updateNodeState: (id, newState, metadata) => set((state) => {
47
+ const node = state.nodes[id];
48
+ if (!node) return state;
49
+ return {
50
+ nodes: {
51
+ ...state.nodes,
52
+ [id]: {
53
+ ...node,
54
+ state: newState,
55
+ metadata: metadata ? { ...node.metadata, ...metadata } : node.metadata
56
+ }
57
+ }
58
+ };
59
+ }),
60
+
61
+ removeNode: (id) => set((state) => {
62
+ const newNodes = { ...state.nodes };
63
+ delete newNodes[id];
64
+ return { nodes: newNodes };
65
+ })
66
+ }));
@@ -0,0 +1,19 @@
1
+ import { create } from 'zustand';
2
+ import { ViewType } from '../types';
3
+
4
+ interface UIState {
5
+ currentView: ViewType;
6
+ searchTerm: string;
7
+
8
+ // Actions
9
+ setCurrentView: (view: ViewType) => void;
10
+ setSearchTerm: (term: string) => void;
11
+ }
12
+
13
+ export const useUIStore = create<UIState>((set) => ({
14
+ currentView: 'login',
15
+ searchTerm: '',
16
+
17
+ setCurrentView: (view) => set({ currentView: view }),
18
+ setSearchTerm: (term) => set({ searchTerm: term })
19
+ }));
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ // 1. Vistas Disponibles en la App
2
+ export type ViewType = 'login' | 'madefront_chat' | 'madefront_kanban' | 'madefront_excel' | 'workflow_admin';
3
+
4
+ // 2. Roles del Sistema (Los Cortex)
5
+ export type Role = 'NONE' | 'GERENCIA' | 'CAJA' | 'PLANTA' | 'DESPACHO';
6
+
7
+ // 3. Estados del Pedido (El Flujo)
8
+ export type OrderStatus = 'DISEÑO' | 'OPTIMIZACION' | 'CAJA' | 'PRODUCCION' | 'DESPACHO' | 'ENTREGADO';
9
+
10
+ // 4. Máquinas Disponibles
11
+ export type MachineType = 'SIGMA' | 'GABBIANI' | 'HOMAG' | 'PENDIENTE';
12
+
13
+ // 5. El Pedido Maestro
14
+ export interface MadefrontOrder {
15
+ id: string; // MF-2026-001
16
+ client: string;
17
+ whatsapp: string; // +57...
18
+ material: string; // Ej: Rh 15mm Blanco
19
+ measurements: string; // Texto o Link
20
+ sheets: number; // Cantidad de láminas (Para el SLA)
21
+
22
+ // Datos Calculados por el "Agente"
23
+ status: OrderStatus;
24
+ machine: MachineType;
25
+ slaHours: number; // 24 o 72
26
+ createdAt: string; // ISO Date
27
+ isUrgent?: boolean; // Bandera de prioridad
28
+
29
+ // Trazabilidad
30
+ isPaid: boolean; // Bloqueo de Caja
31
+ logs: string[]; // Historial de cambios
32
+ }
33
+
34
+ export interface AgentCommand {
35
+ action: 'CHANGE_VIEW' | 'SEARCH_ORDER' | 'UNKNOWN';
36
+ payload?: any;
37
+ message?: string;
38
+ }
@@ -0,0 +1,52 @@
1
+ export const playUISound = (type: 'pop' | 'swoosh' | 'success' | 'error') => {
2
+ try {
3
+ const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
4
+ if (!AudioContextClass) return;
5
+
6
+ const ctx = new AudioContextClass();
7
+ const osc = ctx.createOscillator();
8
+ const gain = ctx.createGain();
9
+
10
+ osc.connect(gain);
11
+ gain.connect(ctx.destination);
12
+
13
+ const now = ctx.currentTime;
14
+
15
+ if (type === 'pop') {
16
+ osc.type = 'sine';
17
+ osc.frequency.setValueAtTime(400, now);
18
+ osc.frequency.exponentialRampToValueAtTime(600, now + 0.1);
19
+ gain.gain.setValueAtTime(0.1, now);
20
+ gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
21
+ osc.start(now);
22
+ osc.stop(now + 0.1);
23
+ } else if (type === 'swoosh') {
24
+ osc.type = 'triangle';
25
+ osc.frequency.setValueAtTime(150, now);
26
+ osc.frequency.exponentialRampToValueAtTime(300, now + 0.25);
27
+ gain.gain.setValueAtTime(0.05, now);
28
+ gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25);
29
+ osc.start(now);
30
+ osc.stop(now + 0.25);
31
+ } else if (type === 'success') {
32
+ osc.type = 'sine';
33
+ osc.frequency.setValueAtTime(440, now); // A4
34
+ osc.frequency.setValueAtTime(554.37, now + 0.1); // C#5
35
+ gain.gain.setValueAtTime(0.1, now);
36
+ gain.gain.setValueAtTime(0.1, now + 0.1);
37
+ gain.gain.linearRampToValueAtTime(0, now + 0.3);
38
+ osc.start(now);
39
+ osc.stop(now + 0.3);
40
+ } else if (type === 'error') {
41
+ osc.type = 'sawtooth';
42
+ osc.frequency.setValueAtTime(200, now);
43
+ osc.frequency.exponentialRampToValueAtTime(100, now + 0.2);
44
+ gain.gain.setValueAtTime(0.1, now);
45
+ gain.gain.linearRampToValueAtTime(0, now + 0.2);
46
+ osc.start(now);
47
+ osc.stop(now + 0.2);
48
+ }
49
+ } catch (e) {
50
+ console.error("Audio playback failed", e);
51
+ }
52
+ };
@@ -0,0 +1,62 @@
1
+ import { Router } from 'express';
2
+ import { Server } from 'socket.io';
3
+ import { IServerPlugin } from '@decido/sdk';
4
+ import { plantaMigrations } from './migrations';
5
+
6
+ /**
7
+ * Dependencias físicas de la industria privada proporcionadas
8
+ * por el Host para inyección y enrutamiento en el Gemelo Digital.
9
+ */
10
+ export interface PlantaErpBackendDeps {
11
+ digitalTwinRouter: Router;
12
+ simulationRouter: Router;
13
+ simulationStudioRouter: Router;
14
+ trackingRouter: Router;
15
+ whatsappAutomationRouter: Router;
16
+ whatsappWebhookRouter: Router;
17
+ initServicesHook?: () => Promise<void>;
18
+ initSocketsHook?: (io: Server) => void;
19
+ }
20
+
21
+ /**
22
+ * Crea la Extensión B2B para Planta Industria, conectando
23
+ * el Engine de Renderizado, Simulaciones y Automatización WA.
24
+ */
25
+ export const createPlantaErpServerPlugin = (deps: PlantaErpBackendDeps): IServerPlugin => ({
26
+ name: 'planta_erp_backend',
27
+
28
+ mountRoutes: (apiRouter: Router) => {
29
+ // Enrutamiento Patentado Industrial
30
+ apiRouter.use('/twin', deps.digitalTwinRouter);
31
+ apiRouter.use('/simulation', deps.simulationRouter);
32
+ apiRouter.use('/scenarios', deps.simulationStudioRouter);
33
+
34
+ // Alias de retrocompatibilidad
35
+ apiRouter.use('/simulation/scenarios', deps.simulationStudioRouter);
36
+
37
+ apiRouter.use('/track', deps.trackingRouter);
38
+ apiRouter.use('/whatsapp', deps.whatsappAutomationRouter);
39
+ apiRouter.use('/webhooks/whatsapp', deps.whatsappWebhookRouter);
40
+ },
41
+
42
+ mountSockets: (io: Server) => {
43
+ if (deps.initSocketsHook) {
44
+ deps.initSocketsHook(io);
45
+ }
46
+ },
47
+
48
+ initServices: async () => {
49
+ console.log('[PlantaErpServerPlugin] Initializing Industrial services...');
50
+ if (deps.initServicesHook) {
51
+ await deps.initServicesHook();
52
+ }
53
+ },
54
+
55
+ getMigrations: async () => {
56
+ return {
57
+ pluginName: 'planta_erp_backend',
58
+ schemaName: 'planta',
59
+ migrations: plantaMigrations
60
+ };
61
+ }
62
+ });
@@ -0,0 +1,33 @@
1
+ export const plantaMigrations = [
2
+ {
3
+ name: '001_init_planta_schema',
4
+ sql: `
5
+ -- 1. Create Schema
6
+ CREATE SCHEMA IF NOT EXISTS planta;
7
+
8
+ -- 2. Create Base Machine Nodes Table (Industrial Property)
9
+ CREATE TABLE IF NOT EXISTS planta.machine_nodes (
10
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
11
+ tenant_id UUID NOT NULL,
12
+ name VARCHAR(255) NOT NULL,
13
+ node_type VARCHAR(100) NOT NULL,
14
+ metadata JSONB DEFAULT '{}',
15
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
16
+ );
17
+
18
+ -- 3. Enforce RLS (Zero-Trust Security)
19
+ ALTER TABLE planta.machine_nodes ENABLE ROW LEVEL SECURITY;
20
+
21
+ DO $$
22
+ BEGIN
23
+ IF NOT EXISTS (
24
+ SELECT 1 FROM pg_policies
25
+ WHERE schemaname = 'planta' AND tablename = 'machine_nodes' AND policyname = 'tenant_isolation_policy'
26
+ ) THEN
27
+ CREATE POLICY tenant_isolation_policy ON planta.machine_nodes
28
+ USING (tenant_id = nullif(current_setting('app.current_tenant', true), '')::uuid);
29
+ END IF;
30
+ END $$;
31
+ `
32
+ }
33
+ ];
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": ".",
6
+ "moduleResolution": "Bundler",
7
+ "lib": [
8
+ "DOM",
9
+ "DOM.Iterable",
10
+ "ES2023"
11
+ ]
12
+ },
13
+ "include": [
14
+ "index.ts",
15
+ "manifest.ts",
16
+ "src/**/*.ts",
17
+ "src/**/*.tsx"
18
+ ],
19
+ "exclude": [
20
+ "node_modules",
21
+ "dist"
22
+ ]
23
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["index.ts"],
5
+ format: ["cjs", "esm"],
6
+ dts: true,
7
+ clean: true,
8
+ });
package/vite.config.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+
5
+ export default defineConfig({
6
+ base: 'http://localhost:5004/',
7
+ plugins: [
8
+ tailwindcss(),
9
+ react()
10
+ ],
11
+ server: {
12
+ host: true,
13
+ port: 5004,
14
+ strictPort: true,
15
+ cors: true
16
+ },
17
+ preview: {
18
+ host: true,
19
+ port: 5004,
20
+ strictPort: true,
21
+ cors: true
22
+ },
23
+ build: {
24
+ modulePreload: false,
25
+ target: ['chrome89', 'edge89', 'firefox89', 'safari15'],
26
+ minify: false,
27
+ cssCodeSplit: false,
28
+ rollupOptions: {
29
+ output: {
30
+ assetFileNames: (assetInfo) => {
31
+ if (assetInfo.name === 'style.css' || assetInfo.name?.endsWith('.css')) {
32
+ return 'assets/style.css';
33
+ }
34
+ return 'assets/[name]-[hash][extname]';
35
+ }
36
+ }
37
+ }
38
+ }
39
+ });