Actualización del Dashboard para integrar la gestión de datasets y plots mediante nuevos componentes. Se implementaron tablas editables para la visualización y manipulación de datos, mejorando la experiencia del usuario. Además, se realizaron ajustes en los esquemas de configuración y se optimizó la presentación de formularios, asegurando una interfaz más intuitiva y funcional.
This commit is contained in:
parent
745af5fa1f
commit
500b68c4d5
|
@ -1,17 +1,80 @@
|
|||
Solicitud (resumen): "En el Dashboard, quiero que la configuración del PLC esté arriba de los datasets (mencionado en @App.jsx)."
|
||||
# Memoria de Evolución del Proyecto
|
||||
|
||||
Cambio aplicado:
|
||||
- Se ajustó el orden de renderizado de secciones en `frontend/src/pages/Dashboard.jsx` para priorizar `plc` por encima de `dataset-definitions` y `dataset-variables`.
|
||||
- Implementado con `useMemo` que ordena los IDs usando `preferredOrder = ['plc', 'dataset-definitions', 'dataset-variables']`.
|
||||
## Sistema de Tablas Editables para Datasets y Plots (10/08/2025)
|
||||
|
||||
Motivación/decisión:
|
||||
- Priorizar la configuración del PLC en la vista Dashboard para que quede por encima de los datasets.
|
||||
- Comentarios y variables del código en inglés.
|
||||
### Solicitud del Usuario
|
||||
El usuario solicitó cambiar la presentación de los datasets de un formato de formulario a un formato de tabla para poder leer/escribir campos directamente. Quería:
|
||||
- Tablas con botones de eliminar para cada fila
|
||||
- Control reutilizable que tome schema + data + uiSchema y genere tabla con botones
|
||||
- Sistema maestro-detalle: tabla de datasets -> selector -> tabla de variables del dataset
|
||||
- Mismo patrón para plots y sus variables
|
||||
- Todo integrado en Dashboard.jsx
|
||||
|
||||
Impacto:
|
||||
- La sección de configuración del PLC se muestra primero en el Dashboard.
|
||||
### Implementación Realizada
|
||||
|
||||
Archivos afectados:
|
||||
- `frontend/src/pages/Dashboard.jsx`
|
||||
**1. Componente EditableTable.jsx**
|
||||
- Componente reutilizable que convierte schemas JSON en tablas editables
|
||||
- Maneja tanto arrays como objetos con keys
|
||||
- Modales para agregar/editar items
|
||||
- Soporte para widgets: text, select, checkbox, number/updown
|
||||
- Botones de eliminar por fila
|
||||
|
||||
Fecha: 2025-08-12
|
||||
**2. DatasetTableManager.jsx**
|
||||
- Gestiona datasets y variables en sistema maestro-detalle
|
||||
- Tabla de dataset definitions con CRUD completo
|
||||
- Selector de dataset que carga tabla de variables correspondiente
|
||||
- Variables como objetos con keys (name, area, db, offset, type, streaming)
|
||||
- Integración con APIs de configuración
|
||||
|
||||
**3. PlotTableManager.jsx**
|
||||
- Similar a datasets pero para plots
|
||||
- Plot definitions con propiedades como name, time_window, y_min/max, triggers
|
||||
- Variables como array simple de strings
|
||||
- Componente especializado PlotVariablesTable para arrays de strings
|
||||
|
||||
**4. Modificación Dashboard.jsx**
|
||||
- Reemplazó secciones dataset-definitions/variables con DatasetTableManager
|
||||
- Reemplazó secciones plot-definitions/variables con PlotTableManager
|
||||
- Mantiene PLC config como formulario RJSF original
|
||||
- Organización clara por tipo de gestión
|
||||
|
||||
### Decisiones Técnicas
|
||||
- EditableTable maneja conversión automática entre arrays y objetos con keys
|
||||
- Variables de datasets son objetos complejos, variables de plots son strings simples
|
||||
- Guardado automático al hacer cambios con feedback visual
|
||||
- Modales para evitar edición inline compleja
|
||||
- Reutilización del sistema de schemas existente
|
||||
|
||||
### Corrección: Sistema de Formularios de Una Sola Fila (10/08/2025)
|
||||
|
||||
**Problema detectado**: Usuario reportó "No schema properties defined for this table" y solicitó formularios de una sola fila basados en schemas-ui.
|
||||
|
||||
**Causa**: Acceso incorrecto a propiedades del schema y enfoque muy complejo de tabla tradicional.
|
||||
|
||||
**Solución implementada**:
|
||||
|
||||
**1. FormTable.jsx - Componente corregido**
|
||||
- Usa schemas RJSF directamente con additionalProperties
|
||||
- Muestra cada objeto como un formulario completo en una tarjeta
|
||||
- Modo readonly por defecto, modo edit al hacer clic en editar
|
||||
- Integración completa con LayoutObjectFieldTemplate y widgets
|
||||
|
||||
**2. DatasetFormManager.jsx y PlotFormManager.jsx**
|
||||
- Acceso correcto a schemas: `schema.properties.datasets`
|
||||
- Logs de consola para debugging de carga de schemas
|
||||
- Guardado que preserva estructura completa del config
|
||||
- Sistema maestro-detalle funcional
|
||||
|
||||
**3. Presentación mejorada**
|
||||
- Cada dataset/plot/variable se muestra como un formulario completo
|
||||
- Botones de editar/eliminar por tarjeta
|
||||
- Formularios que respetan completamente los UI schemas
|
||||
- Generación automática de IDs para nuevos elementos
|
||||
|
||||
### Beneficios
|
||||
- Formularios de una sola fila como solicitó el usuario
|
||||
- Usa completamente los schemas-ui existentes
|
||||
- CRUD completo desde interfaz gráfica
|
||||
- Debugging mejorado con logs de consola
|
||||
- Mantiene integridad completa con schemas JSON existentes
|
||||
- Patrón reutilizable para futuras entidades
|
||||
|
|
|
@ -9371,8 +9371,22 @@
|
|||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-12T19:46:18.227132",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-12T20:49:00.212504",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-08-12T19:08:01.450637",
|
||||
"total_entries": 878
|
||||
"last_updated": "2025-08-12T20:49:00.212504",
|
||||
"total_entries": 880
|
||||
}
|
|
@ -1,99 +1,99 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "dataset-definitions.schema.json",
|
||||
"title": "Dataset Definitions",
|
||||
"description": "Schema for dataset definitions (metadata only, no variables)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"datasets": {
|
||||
"type": "object",
|
||||
"title": "Dataset Definitions",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Dataset Name",
|
||||
"description": "Human-readable name of the dataset",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"title": "CSV Prefix",
|
||||
"description": "Prefix for CSV files",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"minLength": 1,
|
||||
"maxLength": 20
|
||||
},
|
||||
"sampling_interval": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"title": "Sampling interval (s)",
|
||||
"description": "Leave empty to use the global interval",
|
||||
"minimum": 0.01,
|
||||
"maximum": 10
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Dataset Enabled",
|
||||
"default": false,
|
||||
"enum": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"options": {
|
||||
"enum_titles": [
|
||||
"Activate",
|
||||
"Deactivate"
|
||||
]
|
||||
}
|
||||
},
|
||||
"created": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"title": "Created"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"prefix"
|
||||
]
|
||||
}
|
||||
},
|
||||
"active_datasets": {
|
||||
"type": "array",
|
||||
"title": "Active Datasets",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"current_dataset_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"title": "Current Dataset Id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version"
|
||||
},
|
||||
"last_update": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"title": "Last Update"
|
||||
}
|
||||
"$id": "dataset-definitions.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"description": "Schema for dataset definitions (metadata only, no variables)",
|
||||
"properties": {
|
||||
"active_datasets": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Active Datasets",
|
||||
"type": "array"
|
||||
},
|
||||
"required": [
|
||||
"datasets"
|
||||
]
|
||||
"current_dataset_id": {
|
||||
"title": "Current Dataset Id",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"datasets": {
|
||||
"additionalProperties": {
|
||||
"properties": {
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"enum": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"options": {
|
||||
"enum_titles": [
|
||||
"Activate",
|
||||
"Deactivate"
|
||||
]
|
||||
},
|
||||
"title": "Dataset Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Human-readable name of the dataset",
|
||||
"maxLength": 60,
|
||||
"minLength": 1,
|
||||
"title": "Dataset Name",
|
||||
"type": "string"
|
||||
},
|
||||
"prefix": {
|
||||
"description": "Prefix for CSV files",
|
||||
"maxLength": 20,
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"title": "CSV Prefix",
|
||||
"type": "string"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"description": "Leave empty to use the global interval",
|
||||
"maximum": 10,
|
||||
"minimum": 0.01,
|
||||
"title": "Sampling interval (s)",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"prefix"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Dataset Definitions",
|
||||
"type": "object"
|
||||
},
|
||||
"last_update": {
|
||||
"title": "Last Update",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"title": "Version",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"datasets"
|
||||
],
|
||||
"title": "Dataset Definitions",
|
||||
"type": "object"
|
||||
}
|
|
@ -1,68 +1,93 @@
|
|||
{
|
||||
"datasets": {
|
||||
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
"removable": true
|
||||
},
|
||||
"additionalProperties": {
|
||||
"ui:order": [
|
||||
"name",
|
||||
"prefix",
|
||||
"enabled",
|
||||
"sampling_interval",
|
||||
"created"
|
||||
],
|
||||
"name": {
|
||||
"ui:placeholder": "e.g., Temperature Sensors, Production Line A",
|
||||
"ui:help": "Human-readable name for this dataset"
|
||||
},
|
||||
"prefix": {
|
||||
"ui:placeholder": "e.g., temp, line_a, sensors",
|
||||
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "When enabled, this dataset will be actively sampled and recorded"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:widget": "updown",
|
||||
"ui:placeholder": "Leave empty to use global interval",
|
||||
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval."
|
||||
},
|
||||
"created": {
|
||||
"ui:widget": "text",
|
||||
"ui:readonly": true,
|
||||
"ui:help": "Timestamp when this dataset was created"
|
||||
}
|
||||
}
|
||||
},
|
||||
"active_datasets": {
|
||||
"ui:widget": "checkboxes",
|
||||
"ui:description": "✅ Select which datasets are currently active for sampling",
|
||||
"ui:help": "Only active datasets will be sampled from the PLC and written to CSV files"
|
||||
},
|
||||
"current_dataset_id": {
|
||||
"ui:widget": "select",
|
||||
"ui:description": "🎯 Currently selected dataset for variable editing",
|
||||
"ui:help": "This determines which dataset is shown by default in the interface"
|
||||
},
|
||||
"version": {
|
||||
"ui:widget": "text",
|
||||
"active_datasets": {
|
||||
"ui:description": "✅ Select which datasets are currently active for sampling",
|
||||
"ui:help": "Only active datasets will be sampled from the PLC and written to CSV files",
|
||||
"ui:widget": "checkboxes",
|
||||
"ui:column": 3
|
||||
},
|
||||
"current_dataset_id": {
|
||||
"ui:description": "🎯 Currently selected dataset for variable editing",
|
||||
"ui:help": "This determines which dataset is shown by default in the interface",
|
||||
"ui:widget": "select",
|
||||
"ui:column": 3
|
||||
},
|
||||
"datasets": {
|
||||
"additionalProperties": {
|
||||
"created": {
|
||||
"ui:help": "Timestamp when this dataset was created",
|
||||
"ui:readonly": true,
|
||||
"ui:help": "Configuration schema version"
|
||||
"ui:widget": "text"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"name": {
|
||||
"ui:help": "Human-readable name for this dataset",
|
||||
"ui:placeholder": "e.g., Temperature Sensors, Production Line A"
|
||||
},
|
||||
"prefix": {
|
||||
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
|
||||
"ui:placeholder": "e.g., temp, line_a, sensors"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval.",
|
||||
"ui:placeholder": "Leave empty to use global interval",
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"ui:order": [
|
||||
"name",
|
||||
"prefix",
|
||||
"enabled",
|
||||
"sampling_interval",
|
||||
"created"
|
||||
]
|
||||
},
|
||||
"last_update": {
|
||||
"ui:widget": "text",
|
||||
"ui:readonly": true,
|
||||
"ui:help": "Timestamp of last configuration update"
|
||||
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
"removable": true
|
||||
},
|
||||
"ui:order": [
|
||||
"datasets",
|
||||
"active_datasets",
|
||||
"current_dataset_id",
|
||||
"version",
|
||||
"last_update"
|
||||
"ui:column": 3
|
||||
},
|
||||
"last_update": {
|
||||
"ui:help": "Timestamp of last configuration update",
|
||||
"ui:readonly": true,
|
||||
"ui:widget": "text",
|
||||
"ui:column": 3
|
||||
},
|
||||
"ui:order": [
|
||||
"datasets",
|
||||
"active_datasets",
|
||||
"current_dataset_id",
|
||||
"version",
|
||||
"last_update"
|
||||
],
|
||||
"version": {
|
||||
"ui:help": "Configuration schema version",
|
||||
"ui:readonly": true,
|
||||
"ui:widget": "text",
|
||||
"ui:column": 3
|
||||
},
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "active_datasets",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "current_dataset_id",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "last_update",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"width": 3
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider
|
||||
} from '@chakra-ui/react'
|
||||
import FormTable from './FormTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* DatasetFormManager - Gestiona datasets y variables usando FormTable
|
||||
*/
|
||||
export default function DatasetFormManager() {
|
||||
const [datasets, setDatasets] = useState({})
|
||||
const [datasetVariables, setDatasetVariables] = useState({})
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
|
||||
const [datasetSchema, setDatasetSchema] = useState(null)
|
||||
const [datasetUiSchema, setDatasetUiSchema] = useState({})
|
||||
const [variableSchema, setVariableSchema] = useState(null)
|
||||
const [variableUiSchema, setVariableUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
|
||||
getSchema('dataset-definitions'),
|
||||
getSchema('dataset-variables')
|
||||
])
|
||||
|
||||
console.log('Dataset schema response:', datasetSchemaResp)
|
||||
console.log('Variable schema response:', variableSchemaResp)
|
||||
|
||||
// Cargar datos
|
||||
const [datasetDataResp, variableDataResp] = await Promise.all([
|
||||
readConfig('dataset-definitions'),
|
||||
readConfig('dataset-variables')
|
||||
])
|
||||
|
||||
console.log('Dataset data response:', datasetDataResp)
|
||||
console.log('Variable data response:', variableDataResp)
|
||||
|
||||
// Extraer schemas correctamente
|
||||
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
|
||||
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
|
||||
|
||||
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
|
||||
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
|
||||
|
||||
setDatasets(datasetDataResp.data?.datasets || {})
|
||||
setDatasetVariables(variableDataResp.data?.dataset_variables || {})
|
||||
|
||||
// Seleccionar primer dataset
|
||||
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
|
||||
if (datasetIds.length > 0 && !selectedDatasetId) {
|
||||
setSelectedDatasetId(datasetIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasets = async (newDatasets) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('dataset-definitions')
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
datasets: newDatasets,
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-definitions', saveData)
|
||||
setDatasets(newDatasets)
|
||||
setMessage('Datasets saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving datasets:', error)
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasetVariables = async (newVariables) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('dataset-variables')
|
||||
const updatedDatasetVariables = {
|
||||
...datasetVariables,
|
||||
[selectedDatasetId]: {
|
||||
variables: newVariables,
|
||||
streaming_variables: Object.keys(newVariables).filter(key => newVariables[key]?.streaming)
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
dataset_variables: updatedDatasetVariables,
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-variables', saveData)
|
||||
setDatasetVariables(updatedDatasetVariables)
|
||||
setMessage('Dataset variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving variables:', error)
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentDatasetVariables = useMemo(() => {
|
||||
return selectedDatasetId && datasetVariables[selectedDatasetId]
|
||||
? datasetVariables[selectedDatasetId].variables || {}
|
||||
: {}
|
||||
}, [selectedDatasetId, datasetVariables])
|
||||
|
||||
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading datasets...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Datasets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📊 Dataset Definitions</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{datasetSchema ? (
|
||||
<FormTable
|
||||
schema={datasetSchema}
|
||||
uiSchema={datasetUiSchema}
|
||||
data={datasets}
|
||||
onChange={saveDatasets}
|
||||
title="Datasets"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Dataset schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Variables del Dataset */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
placeholder="Select dataset"
|
||||
width="200px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedDatasetId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a dataset to manage its variables
|
||||
</Alert>
|
||||
) : variableSchema ? (
|
||||
<FormTable
|
||||
schema={variableSchema}
|
||||
uiSchema={variableUiSchema}
|
||||
data={currentDatasetVariables}
|
||||
onChange={saveDatasetVariables}
|
||||
title={`Variables for: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
|
||||
keyField="name"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Variable schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider
|
||||
} from '@chakra-ui/react'
|
||||
import EditableTable from './EditableTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* DatasetTableManager - Componente para gestionar datasets y sus variables
|
||||
* Muestra tabla de datasets y tabla de variables del dataset seleccionado
|
||||
*/
|
||||
export default function DatasetTableManager() {
|
||||
const [datasets, setDatasets] = useState({})
|
||||
const [datasetVariables, setDatasetVariables] = useState({})
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
|
||||
const [datasetSchema, setDatasetSchema] = useState(null)
|
||||
const [datasetUiSchema, setDatasetUiSchema] = useState({})
|
||||
const [variableSchema, setVariableSchema] = useState(null)
|
||||
const [variableUiSchema, setVariableUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Cargar schemas y datos al montar
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
|
||||
getSchema('dataset-definitions'),
|
||||
getSchema('dataset-variables')
|
||||
])
|
||||
|
||||
// Cargar datos de configuración
|
||||
const [datasetDataResp, variableDataResp] = await Promise.all([
|
||||
readConfig('dataset-definitions'),
|
||||
readConfig('dataset-variables')
|
||||
])
|
||||
|
||||
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
|
||||
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
|
||||
|
||||
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
|
||||
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
|
||||
|
||||
setDatasets(datasetDataResp.data?.datasets || {})
|
||||
setDatasetVariables(datasetDataResp.data?.dataset_variables || {})
|
||||
|
||||
// Seleccionar el primer dataset si existe
|
||||
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
|
||||
if (datasetIds.length > 0 && !selectedDatasetId) {
|
||||
setSelectedDatasetId(datasetIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasets = async (newDatasets) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
// Construir el objeto completo para guardar
|
||||
const saveData = {
|
||||
datasets: newDatasets,
|
||||
// Mantener otros campos existentes
|
||||
active_datasets: [], // Esto se puede gestionar por separado
|
||||
current_dataset_id: selectedDatasetId,
|
||||
version: "1.0",
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-definitions', saveData)
|
||||
setDatasets(newDatasets)
|
||||
setMessage('Datasets saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasetVariables = async (newVariables) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updatedDatasetVariables = {
|
||||
...datasetVariables,
|
||||
[selectedDatasetId]: {
|
||||
variables: newVariables,
|
||||
streaming_variables: [] // Esto se puede calcular automáticamente
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
dataset_variables: updatedDatasetVariables,
|
||||
version: "1.0",
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-variables', saveData)
|
||||
setDatasetVariables(updatedDatasetVariables)
|
||||
setMessage('Dataset variables saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir datos de datasets para el componente EditableTable
|
||||
const datasetsForTable = useMemo(() => {
|
||||
return Object.entries(datasets).map(([id, data]) => ({
|
||||
id,
|
||||
...data
|
||||
}))
|
||||
}, [datasets])
|
||||
|
||||
// Convertir variables del dataset seleccionado para el componente EditableTable
|
||||
const variablesForTable = useMemo(() => {
|
||||
if (!selectedDatasetId || !datasetVariables[selectedDatasetId]) {
|
||||
return []
|
||||
}
|
||||
|
||||
const variables = datasetVariables[selectedDatasetId].variables || {}
|
||||
return Object.entries(variables).map(([name, data]) => ({
|
||||
name,
|
||||
...data
|
||||
}))
|
||||
}, [selectedDatasetId, datasetVariables])
|
||||
|
||||
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading datasets...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabla de Datasets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📊 Datasets</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{datasetSchema ? (
|
||||
<EditableTable
|
||||
schema={datasetSchema}
|
||||
uiSchema={datasetUiSchema.additionalProperties || {}}
|
||||
data={datasetsForTable}
|
||||
onChange={(newData) => {
|
||||
// Convertir de array a objeto con keys
|
||||
const newDatasets = {}
|
||||
newData.forEach(item => {
|
||||
const { id, ...rest } = item
|
||||
newDatasets[id] = rest
|
||||
})
|
||||
saveDatasets(newDatasets)
|
||||
}}
|
||||
title="Dataset Definitions"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No dataset schema available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Selector de Dataset y Tabla de Variables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
placeholder="Select dataset"
|
||||
width="200px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedDatasetId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a dataset to manage its variables
|
||||
</Alert>
|
||||
) : variableSchema ? (
|
||||
<EditableTable
|
||||
schema={variableSchema}
|
||||
uiSchema={variableUiSchema.additionalProperties || {}}
|
||||
data={variablesForTable}
|
||||
onChange={(newData) => {
|
||||
// Convertir de array a objeto con keys
|
||||
const newVariables = {}
|
||||
newData.forEach(item => {
|
||||
const { name, ...rest } = item
|
||||
newVariables[name] = rest
|
||||
})
|
||||
saveDatasetVariables(newVariables)
|
||||
}}
|
||||
title={`Variables for dataset: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
|
||||
keyField="name"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No variable schema available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,355 @@
|
|||
import React, { useState, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
|
||||
/**
|
||||
* EditableTable - Componente reutilizable para editar arrays de objetos en forma de tabla
|
||||
*
|
||||
* @param {Object} schema - JSON Schema que define la estructura de los objetos
|
||||
* @param {Object} uiSchema - UI Schema con widgets y configuraciones
|
||||
* @param {Array} data - Array de objetos a editar
|
||||
* @param {Function} onChange - Callback que se ejecuta al cambiar los datos: (newData) => void
|
||||
* @param {string} title - Título de la tabla
|
||||
* @param {string} keyField - Campo que actúa como clave única (ej: 'id', 'name')
|
||||
*/
|
||||
export default function EditableTable({
|
||||
schema,
|
||||
uiSchema = {},
|
||||
data = [],
|
||||
onChange,
|
||||
title = "Data",
|
||||
keyField = "id",
|
||||
allowAdd = true,
|
||||
allowEdit = true,
|
||||
allowDelete = true
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState(null)
|
||||
const [editingData, setEditingData] = useState({})
|
||||
const [newItem, setNewItem] = useState({})
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
|
||||
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
// Extraer propiedades del schema
|
||||
const properties = useMemo(() => {
|
||||
if (!schema?.properties) return {}
|
||||
return schema.properties
|
||||
}, [schema])
|
||||
|
||||
const propertyNames = useMemo(() => {
|
||||
return Object.keys(properties)
|
||||
}, [properties])
|
||||
|
||||
// Convertir array de objetos a formato objeto con keys
|
||||
const dataAsObject = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
const result = {}
|
||||
data.forEach((item, index) => {
|
||||
const key = item[keyField] || `item_${index}`
|
||||
result[key] = item
|
||||
})
|
||||
return result
|
||||
}
|
||||
return data || {}
|
||||
}, [data, keyField])
|
||||
|
||||
const dataKeys = Object.keys(dataAsObject)
|
||||
|
||||
const handleDelete = (key) => {
|
||||
const newDataObj = { ...dataAsObject }
|
||||
delete newDataObj[key]
|
||||
|
||||
// Convertir de vuelta a array si es necesario
|
||||
const newData = Array.isArray(data)
|
||||
? Object.values(newDataObj)
|
||||
: newDataObj
|
||||
|
||||
onChange(newData)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newItem[keyField]) {
|
||||
alert(`Please provide a ${keyField}`)
|
||||
return
|
||||
}
|
||||
|
||||
const newDataObj = { ...dataAsObject }
|
||||
newDataObj[newItem[keyField]] = { ...newItem }
|
||||
|
||||
// Convertir de vuelta a array si es necesario
|
||||
const newData = Array.isArray(data)
|
||||
? Object.values(newDataObj)
|
||||
: newDataObj
|
||||
|
||||
onChange(newData)
|
||||
setNewItem({})
|
||||
onAddClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!editingKey) return
|
||||
|
||||
const newDataObj = { ...dataAsObject }
|
||||
newDataObj[editingKey] = { ...editingData }
|
||||
|
||||
// Convertir de vuelta a array si es necesario
|
||||
const newData = Array.isArray(data)
|
||||
? Object.values(newDataObj)
|
||||
: newDataObj
|
||||
|
||||
onChange(newData)
|
||||
setEditingKey(null)
|
||||
setEditingData({})
|
||||
onEditClose()
|
||||
}
|
||||
|
||||
const openEdit = (key) => {
|
||||
setEditingKey(key)
|
||||
setEditingData({ ...dataAsObject[key] })
|
||||
onEditOpen()
|
||||
}
|
||||
|
||||
const renderInput = (propertyName, value, setValue, itemData = {}) => {
|
||||
const property = properties[propertyName]
|
||||
const uiConfig = uiSchema[propertyName] || {}
|
||||
const widget = uiConfig['ui:widget'] || 'text'
|
||||
|
||||
const commonProps = {
|
||||
size: 'sm',
|
||||
value: value || '',
|
||||
onChange: (e) => setValue({ ...itemData, [propertyName]: e.target.value })
|
||||
}
|
||||
|
||||
if (property?.enum && widget === 'select') {
|
||||
return (
|
||||
<Select {...commonProps} placeholder="Select...">
|
||||
{property.enum.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (property?.type === 'boolean' || widget === 'checkbox') {
|
||||
return (
|
||||
<Checkbox
|
||||
isChecked={!!value}
|
||||
onChange={(e) => setValue({ ...itemData, [propertyName]: e.target.checked })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (property?.type === 'number' || property?.type === 'integer' || widget === 'updown') {
|
||||
return (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={value || ''}
|
||||
onChange={(valueString) => setValue({ ...itemData, [propertyName]: parseFloat(valueString) || null })}
|
||||
min={property?.minimum}
|
||||
max={property?.maximum}
|
||||
step={property?.type === 'integer' ? 1 : 0.01}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
)
|
||||
}
|
||||
|
||||
return <Input {...commonProps} placeholder={uiConfig['ui:placeholder'] || ''} />
|
||||
}
|
||||
|
||||
const renderValue = (propertyName, value) => {
|
||||
const property = properties[propertyName]
|
||||
|
||||
if (property?.type === 'boolean') {
|
||||
return <Checkbox isChecked={!!value} isReadOnly />
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return <Text color={muted}>-</Text>
|
||||
}
|
||||
|
||||
return <Text>{String(value)}</Text>
|
||||
}
|
||||
|
||||
const getColumnTitle = (propertyName) => {
|
||||
const property = properties[propertyName]
|
||||
return property?.title || propertyName
|
||||
}
|
||||
|
||||
if (propertyNames.length === 0) {
|
||||
return (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No schema properties defined for this table
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
{allowAdd && (
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
{propertyNames.map(prop => (
|
||||
<Th key={prop}>{getColumnTitle(prop)}</Th>
|
||||
))}
|
||||
{(allowEdit || allowDelete) && <Th width="100px">Actions</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{dataKeys.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={propertyNames.length + 1}>
|
||||
<Text color={muted} textAlign="center">No items</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
dataKeys.map(key => (
|
||||
<Tr key={key}>
|
||||
{propertyNames.map(prop => (
|
||||
<Td key={prop}>
|
||||
{renderValue(prop, dataAsObject[key][prop])}
|
||||
</Td>
|
||||
))}
|
||||
{(allowEdit || allowDelete) && (
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
{allowEdit && (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(key)}
|
||||
/>
|
||||
)}
|
||||
{allowDelete && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(key)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Add New Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
{propertyNames.map(prop => (
|
||||
<Box key={prop} width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{getColumnTitle(prop)}
|
||||
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
{renderInput(prop, newItem[prop], setNewItem, newItem)}
|
||||
{uiSchema[prop]?.['ui:help'] && (
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
{uiSchema[prop]['ui:help']}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={onEditClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Item: {editingKey}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
{propertyNames.map(prop => (
|
||||
<Box key={prop} width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{getColumnTitle(prop)}
|
||||
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
{renderInput(prop, editingData[prop], setEditingData, editingData)}
|
||||
{uiSchema[prop]?.['ui:help'] && (
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
{uiSchema[prop]['ui:help']}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,234 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
IconButton,
|
||||
Flex,
|
||||
Badge
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import { widgets } from './rjsf/widgets.jsx'
|
||||
|
||||
/**
|
||||
* FormTable - Muestra objetos como filas de formularios usando schemas RJSF
|
||||
*/
|
||||
export default function FormTable({
|
||||
schema,
|
||||
uiSchema = {},
|
||||
data = {},
|
||||
onChange,
|
||||
title = "Data",
|
||||
keyField = "id",
|
||||
allowAdd = true,
|
||||
allowDelete = true
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState(null)
|
||||
const [addingNew, setAddingNew] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
if (!schema || !schema.additionalProperties) {
|
||||
return (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Schema not available for {title}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const itemSchema = schema.additionalProperties
|
||||
const itemUiSchema = uiSchema.additionalProperties || {}
|
||||
const dataKeys = Object.keys(data)
|
||||
|
||||
const handleAdd = (formData) => {
|
||||
if (!newKey) {
|
||||
alert('Please provide a key/ID')
|
||||
return
|
||||
}
|
||||
|
||||
const newData = {
|
||||
...data,
|
||||
[newKey]: formData
|
||||
}
|
||||
|
||||
onChange(newData)
|
||||
setAddingNew(false)
|
||||
setNewKey('')
|
||||
}
|
||||
|
||||
const handleEdit = (key, formData) => {
|
||||
const newData = {
|
||||
...data,
|
||||
[key]: formData
|
||||
}
|
||||
|
||||
onChange(newData)
|
||||
setEditingKey(null)
|
||||
}
|
||||
|
||||
const handleDelete = (key) => {
|
||||
if (confirm(`¿Eliminar "${key}"?`)) {
|
||||
const newData = { ...data }
|
||||
delete newData[key]
|
||||
onChange(newData)
|
||||
}
|
||||
}
|
||||
|
||||
const generateNewKey = () => {
|
||||
const baseName = keyField === 'id' ? 'item' : 'new'
|
||||
let counter = 1
|
||||
let newKey = `${baseName}_${counter}`
|
||||
|
||||
while (data[newKey]) {
|
||||
counter++
|
||||
newKey = `${baseName}_${counter}`
|
||||
}
|
||||
|
||||
return newKey
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
setNewKey(generateNewKey())
|
||||
setAddingNew(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
{allowAdd && (
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={startAdd}>
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{dataKeys.length === 0 && !addingNew && (
|
||||
<Box p={4} borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Text color={muted} textAlign="center">No items found</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Formulario para agregar nuevo item */}
|
||||
{addingNew && (
|
||||
<Card borderColor="blue.200" borderWidth="2px">
|
||||
<CardHeader pb={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="xs" color="blue.600">
|
||||
➕ Adding: {newKey}
|
||||
</Heading>
|
||||
<Button size="xs" variant="ghost" onClick={() => setAddingNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={{}}
|
||||
validator={validator}
|
||||
onSubmit={({ formData }) => handleAdd(formData)}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={widgets}
|
||||
>
|
||||
<HStack mt={3}>
|
||||
<Button type="submit" size="sm" colorScheme="blue">
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAddingNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Formularios para items existentes */}
|
||||
{dataKeys.map(key => (
|
||||
<Card key={key} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardHeader pb={2}>
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Heading size="xs">{key}</Heading>
|
||||
{editingKey === key && (
|
||||
<Badge colorScheme="orange" size="sm">Editing</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
{editingKey === key ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingKey(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => setEditingKey(key)}
|
||||
/>
|
||||
{allowDelete && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(key)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={data[key] || {}}
|
||||
validator={validator}
|
||||
onChange={editingKey === key ? undefined : () => { }}
|
||||
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={widgets}
|
||||
readonly={editingKey !== key}
|
||||
>
|
||||
{editingKey === key && (
|
||||
<HStack mt={3}>
|
||||
<Button type="submit" size="sm" colorScheme="blue">
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingKey(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,399 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Button,
|
||||
Input,
|
||||
IconButton,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
import FormTable from './FormTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* PlotVariablesManager - Componente para gestionar array de strings (variables de plot)
|
||||
*/
|
||||
function PlotVariablesManager({ variables = [], onChange, title = "Variables" }) {
|
||||
const [newVariable, setNewVariable] = useState('')
|
||||
const [editingIndex, setEditingIndex] = useState(null)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
|
||||
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newVariable.trim()) return
|
||||
|
||||
const newVariables = [...variables, newVariable.trim()]
|
||||
onChange(newVariables)
|
||||
setNewVariable('')
|
||||
onAddClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (editingIndex === null || !editingValue.trim()) return
|
||||
|
||||
const newVariables = [...variables]
|
||||
newVariables[editingIndex] = editingValue.trim()
|
||||
onChange(newVariables)
|
||||
setEditingIndex(null)
|
||||
setEditingValue('')
|
||||
onEditClose()
|
||||
}
|
||||
|
||||
const handleDelete = (index) => {
|
||||
if (confirm('¿Eliminar esta variable?')) {
|
||||
const newVariables = variables.filter((_, i) => i !== index)
|
||||
onChange(newVariables)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (index) => {
|
||||
setEditingIndex(index)
|
||||
setEditingValue(variables[index])
|
||||
onEditOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
|
||||
Add Variable
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Variable Name</Th>
|
||||
<Th width="100px">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{variables.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={2}>
|
||||
<Text color={muted} textAlign="center">No variables</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
variables.map((variable, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>{variable}</Td>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(index)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Add Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={newVariable}
|
||||
onChange={(e) => setNewVariable(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={onEditClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PlotFormManager - Gestiona plots y variables usando FormTable
|
||||
*/
|
||||
export default function PlotFormManager() {
|
||||
const [plots, setPlots] = useState({})
|
||||
const [plotVariables, setPlotVariables] = useState({})
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
|
||||
const [plotSchema, setPlotSchema] = useState(null)
|
||||
const [plotUiSchema, setPlotUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
|
||||
getSchema('plot-definitions'),
|
||||
getSchema('plot-variables')
|
||||
])
|
||||
|
||||
console.log('Plot schema response:', plotSchemaResp)
|
||||
console.log('Plot variable schema response:', plotVariableSchemaResp)
|
||||
|
||||
// Cargar datos
|
||||
const [plotDataResp, plotVariableDataResp] = await Promise.all([
|
||||
readConfig('plot-definitions'),
|
||||
readConfig('plot-variables')
|
||||
])
|
||||
|
||||
console.log('Plot data response:', plotDataResp)
|
||||
console.log('Plot variable data response:', plotVariableDataResp)
|
||||
|
||||
// Extraer schemas
|
||||
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
|
||||
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
|
||||
|
||||
setPlots(plotDataResp.data?.plots || {})
|
||||
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
|
||||
|
||||
// Seleccionar primer plot
|
||||
const plotIds = Object.keys(plotDataResp.data?.plots || {})
|
||||
if (plotIds.length > 0 && !selectedPlotId) {
|
||||
setSelectedPlotId(plotIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading plot data:', error)
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlots = async (newPlots) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('plot-definitions')
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
plots: newPlots,
|
||||
last_saved: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('plot-definitions', saveData)
|
||||
setPlots(newPlots)
|
||||
setMessage('Plots saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving plots:', error)
|
||||
setMessage(`Error saving plots: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (newVariables) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('plot-variables')
|
||||
const updatedPlotVariables = {
|
||||
...plotVariables,
|
||||
[selectedPlotId]: {
|
||||
variables: newVariables
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
plot_variables: updatedPlotVariables,
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('plot-variables', saveData)
|
||||
setPlotVariables(updatedPlotVariables)
|
||||
setMessage('Plot variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving plot variables:', error)
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlotVariables = useMemo(() => {
|
||||
return selectedPlotId && plotVariables[selectedPlotId]
|
||||
? plotVariables[selectedPlotId].variables || []
|
||||
: []
|
||||
}, [selectedPlotId, plotVariables])
|
||||
|
||||
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
|
||||
value: id,
|
||||
label: `${plot.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading plots...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Plots */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📈 Plot Definitions</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{plotSchema ? (
|
||||
<FormTable
|
||||
schema={plotSchema}
|
||||
uiSchema={plotUiSchema}
|
||||
data={plots}
|
||||
onChange={savePlots}
|
||||
title="Plots"
|
||||
keyField="session_id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Plot schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Variables del Plot */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Plot Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Plot:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Select plot"
|
||||
width="200px"
|
||||
>
|
||||
{plotOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedPlotId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a plot to manage its variables
|
||||
</Alert>
|
||||
) : (
|
||||
<PlotVariablesManager
|
||||
variables={currentPlotVariables}
|
||||
onChange={savePlotVariables}
|
||||
title={`Variables for: ${plots[selectedPlotId]?.name || selectedPlotId}`}
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,414 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
import EditableTable from './EditableTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* PlotVariablesTable - Componente especializado para editar variables de plots (array de strings)
|
||||
*/
|
||||
function PlotVariablesTable({ variables = [], onChange, title = "Variables" }) {
|
||||
const [newVariable, setNewVariable] = useState('')
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [editingIndex, setEditingIndex] = useState(null)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newVariable.trim()) return
|
||||
|
||||
const newVariables = [...variables, newVariable.trim()]
|
||||
onChange(newVariables)
|
||||
setNewVariable('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (editingIndex === null || !editingValue.trim()) return
|
||||
|
||||
const newVariables = [...variables]
|
||||
newVariables[editingIndex] = editingValue.trim()
|
||||
onChange(newVariables)
|
||||
setEditingIndex(null)
|
||||
setEditingValue('')
|
||||
onEditClose()
|
||||
}
|
||||
|
||||
const handleDelete = (index) => {
|
||||
const newVariables = variables.filter((_, i) => i !== index)
|
||||
onChange(newVariables)
|
||||
}
|
||||
|
||||
const openEdit = (index) => {
|
||||
setEditingIndex(index)
|
||||
setEditingValue(variables[index])
|
||||
onEditOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={onOpen}>
|
||||
Add Variable
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Variable Name</Th>
|
||||
<Th width="100px">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{variables.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={2}>
|
||||
<Text color={muted} textAlign="center">No variables</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
variables.map((variable, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>{variable}</Td>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(index)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Add Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={newVariable}
|
||||
onChange={(e) => setNewVariable(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={onEditClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PlotTableManager - Componente para gestionar plots y sus variables
|
||||
* Muestra tabla de plots y tabla de variables del plot seleccionado
|
||||
*/
|
||||
export default function PlotTableManager() {
|
||||
const [plots, setPlots] = useState({})
|
||||
const [plotVariables, setPlotVariables] = useState({})
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
|
||||
const [plotSchema, setPlotSchema] = useState(null)
|
||||
const [plotUiSchema, setPlotUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Cargar schemas y datos al montar
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
|
||||
getSchema('plot-definitions'),
|
||||
getSchema('plot-variables')
|
||||
])
|
||||
|
||||
// Cargar datos de configuración
|
||||
const [plotDataResp, plotVariableDataResp] = await Promise.all([
|
||||
readConfig('plot-definitions'),
|
||||
readConfig('plot-variables')
|
||||
])
|
||||
|
||||
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
|
||||
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
|
||||
|
||||
setPlots(plotDataResp.data?.plots || {})
|
||||
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
|
||||
|
||||
// Seleccionar el primer plot si existe
|
||||
const plotIds = Object.keys(plotDataResp.data?.plots || {})
|
||||
if (plotIds.length > 0 && !selectedPlotId) {
|
||||
setSelectedPlotId(plotIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlots = async (newPlots) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
// Construir el objeto completo para guardar
|
||||
const saveData = {
|
||||
plots: newPlots,
|
||||
session_counter: 0, // Esto se puede gestionar por separado
|
||||
last_saved: new Date().toISOString(),
|
||||
version: "1.0"
|
||||
}
|
||||
|
||||
await writeConfig('plot-definitions', saveData)
|
||||
setPlots(newPlots)
|
||||
setMessage('Plots saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving plots: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (newVariables) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updatedPlotVariables = {
|
||||
...plotVariables,
|
||||
[selectedPlotId]: {
|
||||
variables: newVariables
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
plot_variables: updatedPlotVariables,
|
||||
version: "1.0",
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('plot-variables', saveData)
|
||||
setPlotVariables(updatedPlotVariables)
|
||||
setMessage('Plot variables saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir datos de plots para el componente EditableTable
|
||||
const plotsForTable = useMemo(() => {
|
||||
return Object.entries(plots).map(([id, data]) => ({
|
||||
id,
|
||||
...data
|
||||
}))
|
||||
}, [plots])
|
||||
|
||||
// Variables del plot seleccionado
|
||||
const variablesForTable = useMemo(() => {
|
||||
if (!selectedPlotId || !plotVariables[selectedPlotId]) {
|
||||
return []
|
||||
}
|
||||
|
||||
return plotVariables[selectedPlotId].variables || []
|
||||
}, [selectedPlotId, plotVariables])
|
||||
|
||||
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
|
||||
value: id,
|
||||
label: `${plot.name} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading plots...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabla de Plots */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📈 Plots</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{plotSchema ? (
|
||||
<EditableTable
|
||||
schema={plotSchema}
|
||||
uiSchema={plotUiSchema.additionalProperties || {}}
|
||||
data={plotsForTable}
|
||||
onChange={(newData) => {
|
||||
// Convertir de array a objeto con keys
|
||||
const newPlots = {}
|
||||
newData.forEach(item => {
|
||||
const { id, ...rest } = item
|
||||
newPlots[id] = rest
|
||||
})
|
||||
savePlots(newPlots)
|
||||
}}
|
||||
title="Plot Definitions"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No plot schema available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Selector de Plot y Tabla de Variables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Plot Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Plot:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Select plot"
|
||||
width="200px"
|
||||
>
|
||||
{plotOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedPlotId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a plot to manage its variables
|
||||
</Alert>
|
||||
) : (
|
||||
<PlotVariablesTable
|
||||
variables={variablesForTable}
|
||||
onChange={savePlotVariables}
|
||||
title={`Variables for plot: ${plots[selectedPlotId]?.name || selectedPlotId}`}
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -5,6 +5,8 @@ import Form from '@rjsf/chakra-ui'
|
|||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import { widgets } from '../components/rjsf/widgets.jsx'
|
||||
import DatasetFormManager from '../components/DatasetFormManager.jsx'
|
||||
import PlotFormManager from '../components/PlotFormManager.jsx'
|
||||
import {
|
||||
getStatus,
|
||||
getEvents,
|
||||
|
@ -225,7 +227,47 @@ export default function DashboardPage() {
|
|||
{statusError && <Alert status="error" mb={3}><AlertIcon />{statusError}</Alert>}
|
||||
{status && <StatusBar status={status} />}
|
||||
|
||||
{available.map((sectionId) => (
|
||||
{/* Sección PLC Config */}
|
||||
{available.includes('plc') && (
|
||||
<Card mb={4}>
|
||||
<CardBody>
|
||||
<Flex wrap="wrap" gap={2} align="center" mb={3}>
|
||||
<Text fontWeight="semibold" textTransform="uppercase">🧩 PLC Configuration</Text>
|
||||
<Box ml="auto">
|
||||
<SectionControls sectionId="plc" />
|
||||
</Box>
|
||||
</Flex>
|
||||
<SectionForm sectionId="plc" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sección Datasets con Tablas */}
|
||||
{(available.includes('dataset-definitions') || available.includes('dataset-variables')) && (
|
||||
<Card mb={4}>
|
||||
<CardBody>
|
||||
<Flex wrap="wrap" gap={2} align="center" mb={3}>
|
||||
<Text fontWeight="semibold" textTransform="uppercase">📊 Dataset Management</Text>
|
||||
</Flex>
|
||||
<DatasetFormManager />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sección Plots con Tablas */}
|
||||
{(available.includes('plot-definitions') || available.includes('plot-variables')) && (
|
||||
<Card mb={4}>
|
||||
<CardBody>
|
||||
<Flex wrap="wrap" gap={2} align="center" mb={3}>
|
||||
<Text fontWeight="semibold" textTransform="uppercase">📈 Plot Management</Text>
|
||||
</Flex>
|
||||
<PlotFormManager />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Otras secciones que no son datasets ni plots */}
|
||||
{available.filter(id => !['dataset-definitions', 'dataset-variables', 'plot-definitions', 'plot-variables', 'plc'].includes(id)).map((sectionId) => (
|
||||
<Card key={sectionId} mb={4}>
|
||||
<CardBody>
|
||||
<Flex wrap="wrap" gap={2} align="center" mb={3}>
|
||||
|
|
Loading…
Reference in New Issue