diff --git a/src/App.tsx b/src/App.tsx index 638dd0a..697b492 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,49 +1,38 @@ import { useState } from "react"; -import Sidebar from "./pages/Sidebar"; -import TopMenu from "./pages/TopMenu"; -import AreaManagement from "./pages/AreaManagement"; -import OperatorManagement from "./pages/OperatorManagement"; -import DeviceManagement from "./pages/DeviceManagement"; -import DataMonitoring from "./pages/DataMonitoring"; -import DataQuery from "./pages/DataQuery"; -import Home from "./pages/Home"; +import Sidebar from "./components/layout/Sidebar"; +import TopMenu from "./components/layout/TopMenu"; +import Home from "./pages/Home"; +import MetersPage from "./pages/meters/MeterPage"; +import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage"; +import UsersPage from "./pages/UsersPage"; // nueva página +import RolesPage from "./pages/RolesPage"; // nueva página export default function App() { - const [page, setPage] = useState("home"); - const [subPage, setSubPage] = useState("default"); + const [page, setPage] = useState("home"); - const renderContent = () => { + const renderPage = () => { switch (page) { + case "meters": + return ; + case "concentrators": + return ; + case "users": + return ; // nueva + case "roles": + return ; // nueva case "home": - return ; - case "area": - return ; - case "operator": - return ; - case "device-management": - return ; - case "data-monitoring": - return ; - case "data-query": - return ; default: - return
Selecciona una opción
; + return ; } }; return ( -
- {/* SIDEBAR */} +
- - {/* MAIN */} -
- - -
- {renderContent()} -
+
+ +
{renderPage()}
); diff --git a/src/pages/Sidebar.tsx b/src/components/layout/Sidebar.tsx similarity index 71% rename from src/pages/Sidebar.tsx rename to src/components/layout/Sidebar.tsx index e3d9f8f..5a3ac72 100644 --- a/src/pages/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -6,11 +6,18 @@ import { ExpandMore, ExpandLess, Menu, + People, + Key } from "@mui/icons-material"; -export default function Sidebar({ setPage }: any) { +interface SidebarProps { + setPage: (page: string) => void; +} + +export default function Sidebar({ setPage }: SidebarProps) { const [systemOpen, setSystemOpen] = useState(true); const [waterOpen, setWaterOpen] = useState(true); + const [usersOpen, setUsersOpen] = useState(true); // Nuevo const [pinned, setPinned] = useState(false); const [hovered, setHovered] = useState(false); @@ -34,7 +41,6 @@ export default function Sidebar({ setPage }: any) { > - {isExpanded && ( Water System @@ -57,7 +63,7 @@ export default function Sidebar({ setPage }: any) { - {/* SYSTEM SETTINGS */} + {/* PROJECT MANAGEMENT */}
  • )} - {/* WATER METER SYSTEM */} + {/* WATER METER SYSTEM
  • + *} + + {/* SYSTEM USERS */} +
  • + + + {isExpanded && usersOpen && ( +
      +
    • + +
    • +
    • + +
    • +
    + )} +
  • +
    diff --git a/src/pages/TopMenu.tsx b/src/components/layout/TopMenu.tsx similarity index 100% rename from src/pages/TopMenu.tsx rename to src/components/layout/TopMenu.tsx diff --git a/src/components/layout/common/ConfirmModal.tsx b/src/components/layout/common/ConfirmModal.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/AreaManagement.tsx b/src/pages/AreaManagement.tsx deleted file mode 100644 index f504ee3..0000000 --- a/src/pages/AreaManagement.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useState } from "react"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Add, Delete, Refresh, Edit } from "@mui/icons-material"; -import { Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, CircularProgress } from "@mui/material"; - -interface Area { - id: number; - name: string; - no: string; - code: string; - sort: number; - pushAddress: string; - note: string; - time: string; -} - -export default function AreaManagement() { - const [rows, setRows] = useState([ - { id: 1, name: "Operaciones", no: "001", code: "OP01", sort: 1, pushAddress: "Calle 123", note: "Área principal", time: "08:00-17:00" }, - { id: 2, name: "Calidad", no: "002", code: "QA02", sort: 2, pushAddress: "Calle 456", note: "Revisión diaria", time: "09:00-18:00" }, - { id: 3, name: "Mantenimiento", no: "003", code: "MT03", sort: 3, pushAddress: "Calle 789", note: "Turno A", time: "07:00-15:00" }, - ]); - - const [dialogOpen, setDialogOpen] = useState(false); - const [editMode, setEditMode] = useState(false); - const [currentArea, setCurrentArea] = useState({ - id: 0, - name: "", - no: "", - code: "", - sort: 0, - pushAddress: "", - note: "", - time: "", - }); - - const [loading, setLoading] = useState(false); - - // Columns del DataGrid - const columns: GridColDef[] = [ - { field: "id", headerName: "ID", width: 70 }, - { field: "name", headerName: "Área", width: 150 }, - { field: "no", headerName: "Área No.", width: 120 }, - { field: "code", headerName: "Código", width: 120 }, - { field: "sort", headerName: "Sort", width: 80 }, - { field: "pushAddress", headerName: "Push Address", width: 180 }, - { field: "note", headerName: "Notas", width: 200 }, - { field: "time", headerName: "Time", width: 120 }, - { - field: "operate", - headerName: "Operar", - width: 150, - renderCell: (params) => ( -
    - handleEdit(params.row)}> - - - handleDelete(params.row.id)}> - - -
    - ), - }, - ]; - - // FUNCIONES CRUD - const handleDelete = (id: number) => { - if (confirm("¿Deseas eliminar esta área?")) { - setRows(rows.filter(row => row.id !== id)); - } - }; - - const handleEdit = (area: Area) => { - setCurrentArea(area); - setEditMode(true); - setDialogOpen(true); - }; - - const handleAdd = () => { - const newId = rows.length ? Math.max(...rows.map(r => r.id)) + 1 : 1; - setRows([...rows, { ...currentArea, id: newId }]); - setDialogOpen(false); - resetForm(); - }; - - const handleUpdate = () => { - setRows(rows.map(r => (r.id === currentArea.id ? currentArea : r))); - setDialogOpen(false); - resetForm(); - }; - - const resetForm = () => { - setCurrentArea({ id: 0, name: "", no: "", code: "", sort: 0, pushAddress: "", note: "", time: "" }); - setEditMode(false); - }; - - const handleRefresh = () => { - setLoading(true); - setTimeout(() => { - setRows([...rows]); // podrías reemplazar con fetch real - setLoading(false); - }, 1000); - }; - - return ( -
    - - {/* HEADER */} -
    -

    Area Management

    -
    - - - -
    -
    - - {/* TABLA */} -
    - -
    - - {/* DIALOG FORM */} - { setDialogOpen(false); resetForm(); }}> - {editMode ? "Editar Área" : "Agregar Nueva Área"} - - {["name","no","code","sort","pushAddress","note","time"].map((field) => ( - setCurrentArea({ ...currentArea, [field]: field === "sort" ? Number(e.target.value) : e.target.value })} - fullWidth - /> - ))} - - - - - - - - -
    - ); -} diff --git a/src/pages/DataMonitoring.tsx b/src/pages/DataMonitoring.tsx deleted file mode 100644 index c68cca7..0000000 --- a/src/pages/DataMonitoring.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useState } from "react"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Refresh } from "@mui/icons-material"; -import { Button, CircularProgress } from "@mui/material"; - -interface DataMonitoringItem { - id: number; - sort: number; - areaName: string; - meterSn: string; - communicationTime: string; - positiveTotalFlow: number; - batteryStatus: string; - emDisturbance: string; - valveStatus: string; - positiveFlowRate: string; - deviceId: number; - imei: string; - pci: string; - snr: string; - imsi: string; -} - -interface DataMonitoringProps { - subPage: string; -} - -export default function DataMonitoring({ subPage: _subPage }: DataMonitoringProps) { - const [rows, setRows] = useState([ - { - id: 1, - sort: 1, - areaName: "Operaciones", - meterSn: "MTR001", - communicationTime: "2024-12-16 14:25:00", - positiveTotalFlow: 1250.5, - batteryStatus: "Good", - emDisturbance: "Normal", - valveStatus: "Open", - positiveFlowRate: "15.2 L/min", - deviceId: 1001, - imei: "351756051523999", - pci: "100", - snr: "12.5", - imsi: "310260123456789" - }, - { - id: 2, - sort: 2, - areaName: "Calidad", - meterSn: "MTR002", - communicationTime: "2024-12-16 13:45:00", - positiveTotalFlow: 890.3, - batteryStatus: "Low", - emDisturbance: "High", - valveStatus: "Open", - positiveFlowRate: "8.7 L/min", - deviceId: 1002, - imei: "351756051524000", - pci: "101", - snr: "10.8", - imsi: "310260123456790" - }, - { - id: 3, - sort: 3, - areaName: "Mantenimiento", - meterSn: "MTR003", - communicationTime: "2024-12-16 12:30:00", - positiveTotalFlow: 2100.8, - batteryStatus: "Good", - emDisturbance: "Normal", - valveStatus: "Closed", - positiveFlowRate: "0.0 L/min", - deviceId: 1003, - imei: "351756051524001", - pci: "102", - snr: "14.2", - imsi: "310260123456791" - }, - { - id: 4, - sort: 4, - areaName: "Operaciones", - meterSn: "MTR004", - communicationTime: "2024-12-16 11:15:00", - positiveTotalFlow: 567.2, - batteryStatus: "Critical", - emDisturbance: "Normal", - valveStatus: "Open", - positiveFlowRate: "22.1 L/min", - deviceId: 1004, - imei: "351756051524002", - pci: "103", - snr: "9.3", - imsi: "310260123456792" - }, - { - id: 5, - sort: 5, - areaName: "Calidad", - meterSn: "MTR005", - communicationTime: "2024-12-16 10:00:00", - positiveTotalFlow: 3340.1, - batteryStatus: "Good", - emDisturbance: "Low", - valveStatus: "Open", - positiveFlowRate: "18.9 L/min", - deviceId: 1005, - imei: "351756051524003", - pci: "104", - snr: "13.7", - imsi: "310260123456793" - }, - ]); - - const [loading, setLoading] = useState(false); - - const columns: GridColDef[] = [ - { field: "sort", headerName: "Sort", width: 80, type: "number" }, - { field: "areaName", headerName: "Area Name", width: 120 }, - { field: "meterSn", headerName: "Meter S/N", width: 120 }, - { field: "communicationTime", headerName: "Communication Time", width: 160 }, - { field: "positiveTotalFlow", headerName: "Positive Total Flow", width: 140, type: "number" }, - { field: "batteryStatus", headerName: "Battery Status", width: 120 }, - { field: "emDisturbance", headerName: "EM Disturbance", width: 120 }, - { field: "valveStatus", headerName: "Valve Status", width: 110 }, - { field: "positiveFlowRate", headerName: "Positive Flow Rate", width: 140 }, - { field: "deviceId", headerName: "Device ID", width: 100, type: "number" }, - { field: "imei", headerName: "IMEI", width: 140 }, - { field: "pci", headerName: "PCI", width: 80 }, - { field: "snr", headerName: "SNR", width: 80 }, - { field: "imsi", headerName: "IMSI", width: 140 }, - ]; - - const handleRefresh = () => { - setLoading(true); - setTimeout(() => { - setRows([...rows]); - setLoading(false); - }, 1000); - }; - - return ( -
    - -
    -

    Data Monitoring

    -
    - -
    -
    - -
    - -
    - - -
    - ); -} diff --git a/src/pages/DataQuery.tsx b/src/pages/DataQuery.tsx deleted file mode 100644 index 8400261..0000000 --- a/src/pages/DataQuery.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useState } from "react"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Refresh } from "@mui/icons-material"; -import { Button, CircularProgress } from "@mui/material"; - -interface DataQueryItem { - id: number; - sort: number; - areaName: string; - meterSn: string; - communicationTime: string; - positiveTotalFlow: number; - batteryStatus: string; -} - -interface DataQueryProps { - subPage: string; -} - -export default function DataQuery({ subPage: _subPage }: DataQueryProps) { - const [rows, setRows] = useState([ - { - id: 1, - sort: 1, - areaName: "Operaciones", - meterSn: "MTR001", - communicationTime: "2024-12-16 14:25:00", - positiveTotalFlow: 88.97, - batteryStatus: "Good" - }, - { - id: 2, - sort: 2, - areaName: "Calidad", - meterSn: "MTR002", - communicationTime: "2024-12-16 13:45:00", - positiveTotalFlow: 122.82, - batteryStatus: "Low" - }, - { - id: 3, - sort: 3, - areaName: "Mantenimiento", - meterSn: "MTR003", - communicationTime: "2024-12-16 12:30:00", - positiveTotalFlow: 67.45, - batteryStatus: "Good" - }, - { - id: 4, - sort: 4, - areaName: "Operaciones", - meterSn: "MTR004", - communicationTime: "2024-12-16 11:15:00", - positiveTotalFlow: 234.67, - batteryStatus: "Critical" - }, - { - id: 5, - sort: 5, - areaName: "Calidad", - meterSn: "MTR005", - communicationTime: "2024-12-16 10:00:00", - positiveTotalFlow: 156.23, - batteryStatus: "Good" - }, - { - id: 6, - sort: 6, - areaName: "Mantenimiento", - meterSn: "MTR006", - communicationTime: "2024-12-16 09:30:00", - positiveTotalFlow: 78.91, - batteryStatus: "Low" - }, - { - id: 7, - sort: 7, - areaName: "Operaciones", - meterSn: "MTR007", - communicationTime: "2024-12-16 08:45:00", - positiveTotalFlow: 189.34, - batteryStatus: "Good" - }, - { - id: 8, - sort: 8, - areaName: "Calidad", - meterSn: "MTR008", - communicationTime: "2024-12-16 07:20:00", - positiveTotalFlow: 145.78, - batteryStatus: "Critical" - }, - ]); - - const [loading, setLoading] = useState(false); - - const columns: GridColDef[] = [ - { field: "sort", headerName: "Sort", width: 80, type: "number" }, - { field: "areaName", headerName: "Area Name", width: 150 }, - { field: "meterSn", headerName: "Meter S/N", width: 130 }, - { field: "communicationTime", headerName: "Communication Time", width: 180 }, - { field: "positiveTotalFlow", headerName: "Positive Total Flow", width: 160, type: "number" }, - { field: "batteryStatus", headerName: "Battery Status", width: 130 }, - ]; - - const handleRefresh = () => { - setLoading(true); - setTimeout(() => { - setRows([...rows]); - setLoading(false); - }, 1000); - }; - - return ( -
    - -
    -

    Data Query

    -
    - -
    -
    - -
    - -
    - - -
    - ); -} diff --git a/src/pages/DeviceManagement.tsx b/src/pages/DeviceManagement.tsx deleted file mode 100644 index 310b8c1..0000000 --- a/src/pages/DeviceManagement.tsx +++ /dev/null @@ -1,524 +0,0 @@ -import { useEffect, useState } from "react"; -import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; -import MaterialTable from "@material-table/core"; - -interface Device { - id: string; - "Area Name": string; - "Account Number": string; - "User Name": string; - "User Address": string; - "Meter S/N": string; - "Meter Name": string; - "Meter Status": string; - "Protocol Type": string; - "Price No.": string; - "Price Name": string; - "DMA Partition": string; - "Supply Types": string; - "Device ID": string; - "Device Name": string; - "Device Type": string; - "Usage Analysis Type": string; - "Installed Time": string; -} - -interface ApiResponse { - records: Device[]; - next?: string; - prev?: string; - nestedNext?: string; - nestedPrev?: string; -} - -export default function DeviceManagement() { - const [devices, setDevices] = useState([]); - const [search, setSearch] = useState(""); - const [showModal, setShowModal] = useState(false); - const [editingId, setEditingId] = useState(null); - const [activeDevice, setActiveDevice] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const [loading, setLoading] = useState(false); - - const emptyDevice: Omit = { - "Area Name": "", - "Account Number": "", - "User Name": "", - "User Address": "", - "Meter S/N": "", - "Meter Name": "", - "Meter Status": "", - "Protocol Type": "", - "Price No.": "", - "Price Name": "", - "DMA Partition": "", - "Supply Types": "", - "Device ID": "", - "Device Name": "", - "Device Type": "", - "Usage Analysis Type": "", - "Installed Time": "", - }; - - const [form, setForm] = useState>(emptyDevice); - - const loadData = async () => { - setLoading(true); - try { - const response = await fetch( - "/api/v3/data/ppfu31vhv5gf6i0/mp1izvcpok5rk6s/records" - ); - const data: ApiResponse = await response.json(); - setDevices(data.records); - setActiveDevice(null); - setSelectedRows([]); - } catch (error) { - console.error("Error loading devices:", error); - const mockData: Device[] = [ - { - id: "1", - "Area Name": "Operaciones", - "Account Number": "ACC001", - "User Name": "Juan Pérez", - "User Address": "Calle Principal 123", - "Meter S/N": "DEV001", - "Meter Name": "Water Meter A1", - "Meter Status": "Active", - "Protocol Type": "MQTT", - "Price No.": "P001", - "Price Name": "Standard Rate", - "DMA Partition": "Zone A", - "Supply Types": "Water", - "Device ID": "D001", - "Device Name": "Flow Sensor", - "Device Type": "Flow Sensor", - "Usage Analysis Type": "Daily", - "Installed Time": "2024-01-15 10:30:00", - }, - { - id: "2", - "Area Name": "Calidad", - "Account Number": "ACC002", - "User Name": "María García", - "User Address": "Avenida Central 456", - "Meter S/N": "DEV002", - "Meter Name": "Pressure Monitor B2", - "Meter Status": "Active", - "Protocol Type": "LoRa", - "Price No.": "P002", - "Price Name": "Premium Rate", - "DMA Partition": "Zone B", - "Supply Types": "Water", - "Device ID": "D002", - "Device Name": "Pressure Sensor", - "Device Type": "Pressure Sensor", - "Usage Analysis Type": "Hourly", - "Installed Time": "2024-02-20 09:15:00", - }, - ]; - setDevices(mockData); - setSelectedRows([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - }, []); - - const handleSave = () => { - if (editingId) { - setDevices((prev) => - prev.map((device) => - device.id === editingId ? { ...device, ...form } : device - ) - ); - } else { - const newDevice: Device = { - id: Date.now().toString(), - ...form, - }; - setDevices((prev) => [...prev, newDevice]); - } - - setShowModal(false); - setEditingId(null); - setForm(emptyDevice); - }; - - const handleEdit = () => { - if (!activeDevice) return; - setEditingId(activeDevice.id); - setForm({ ...activeDevice }); - setShowModal(true); - }; - - const handleDelete = () => { - if (selectedRows.length === 0) return; - - const message = selectedRows.length === 1 - ? "¿Deseas eliminar este dispositivo?" - : `¿Deseas eliminar ${selectedRows.length} dispositivos?`; - - if (confirm(message)) { - setDevices((prev) => - prev.filter((device) => !selectedRows.some(selected => selected.id === device.id)) - ); - setSelectedRows([]); - setActiveDevice(null); - } - }; - - const filteredDevices = devices.filter((device) => { - const q = search.toLowerCase(); - return ( - device["Area Name"].toLowerCase().includes(q) || - device["User Name"].toLowerCase().includes(q) || - device["Meter S/N"].toLowerCase().includes(q) || - device["Device Name"].toLowerCase().includes(q) - ); - }); - - return ( -
    -
    -
    -
    -

    Device Management

    -

    Water Meter Devices

    -
    - -
    - - - - - - - -
    -
    - - setSearch(e.target.value)} - /> - - 0 ? `(${selectedRows.length} selected)` : ""}`} - columns={[ - { title: "Area Name", field: "Area Name" }, - { title: "Account Number", field: "Account Number" }, - { title: "User Name", field: "User Name" }, - { title: "User Address", field: "User Address" }, - { title: "Meter S/N", field: "Meter S/N" }, - { title: "Meter Name", field: "Meter Name" }, - { - title: "Meter Status", - field: "Meter Status", - render: (rowData) => ( - - {rowData["Meter Status"]} - - ), - }, - { title: "Protocol Type", field: "Protocol Type" }, - { title: "Price No.", field: "Price No." }, - { title: "Price Name", field: "Price Name" }, - { title: "DMA Partition", field: "DMA Partition" }, - { title: "Supply Types", field: "Supply Types" }, - { title: "Device ID", field: "Device ID" }, - { title: "Device Name", field: "Device Name" }, - { title: "Device Type", field: "Device Type" }, - { title: "Usage Analysis Type", field: "Usage Analysis Type" }, - { - title: "Installed Time", - field: "Installed Time", - type: "datetime", - }, - ]} - data={filteredDevices} - onSelectionChange={(rows) => { - const selectedDevices = rows as Device[]; - setSelectedRows(selectedDevices); - // Set active device to the first selected item for editing - setActiveDevice(selectedDevices.length > 0 ? selectedDevices[0] : null); - }} - actions={[ - { - icon: () => , - tooltip: "Edit Device", - onClick: (_event, rowData) => { - setActiveDevice(rowData as Device); - setEditingId((rowData as Device).id); - setForm({ ...(rowData as Device) }); - setShowModal(true); - }, - }, - { - icon: () => , - tooltip: "Delete Device", - onClick: (_event, rowData) => { - setActiveDevice(rowData as Device); - handleDelete(); - }, - }, - ]} - options={{ - actionsColumnIndex: -1, - search: false, - paging: true, - sorting: true, - selection: true, - headerStyle: { - textAlign: "center", - fontWeight: 600, - }, - maxBodyHeight: "500px", - tableLayout: "fixed", - }} - isLoading={loading} - /> -
    - - {showModal && ( -
    -
    -

    - {editingId ? "Edit Device" : "Add Device"} -

    - -
    - - setForm({ ...form, "Area Name": e.target.value }) - } - /> - - - setForm({ ...form, "Account Number": e.target.value }) - } - /> - - - setForm({ ...form, "User Name": e.target.value }) - } - /> - - - setForm({ ...form, "User Address": e.target.value }) - } - /> - - - setForm({ ...form, "Meter S/N": e.target.value }) - } - /> - - - setForm({ ...form, "Meter Name": e.target.value }) - } - /> - - - - - setForm({ ...form, "Protocol Type": e.target.value }) - } - /> - - - setForm({ ...form, "Price No.": e.target.value }) - } - /> - - - setForm({ ...form, "Price Name": e.target.value }) - } - /> - - - setForm({ ...form, "DMA Partition": e.target.value }) - } - /> - - - setForm({ ...form, "Supply Types": e.target.value }) - } - /> - - - setForm({ ...form, "Device ID": e.target.value }) - } - /> - - - setForm({ ...form, "Device Name": e.target.value }) - } - /> - - - setForm({ ...form, "Device Type": e.target.value }) - } - /> - - - setForm({ ...form, "Usage Analysis Type": e.target.value }) - } - /> - - - setForm({ ...form, "Installed Time": e.target.value }) - } - /> -
    - -
    - - -
    -
    -
    - )} - - -
    - ); -} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 83d8b83..887b3a5 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,18 +2,29 @@ import { Cpu, Settings, BarChart3, Bell } from "lucide-react"; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts"; export default function Home() { + // Datos de ejemplo para empresas const companies = [ { name: "Empresa A", tomas: 12, alerts: 2, consumption: 320 }, { name: "Empresa B", tomas: 8, alerts: 0, consumption: 210 }, { name: "Empresa C", tomas: 15, alerts: 1, consumption: 450 }, ]; + // Alertas recientes const alerts = [ { company: "Empresa A", type: "Fuga", time: "Hace 2 horas" }, { company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" }, { company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" }, ]; + // Historial tipo Google + const history = [ + { user: "GRH", action: "Creó un nuevo medidor", target: "SN001", time: "Hace 5 minutos" }, + { user: "CESPT", action: "Actualizó concentrador", target: "Planta 1", time: "Hace 20 minutos" }, + { user: "GRH", action: "Eliminó un usuario", target: "Juan Pérez", time: "Hace 1 hora" }, + { user: "CESPT", action: "Creó un payload", target: "Payload 12", time: "Hace 2 horas" }, + { user: "GRH", action: "Actualizó medidor", target: "SN002", time: "Hace 3 horas" }, + ]; + return (
    @@ -77,6 +88,24 @@ export default function Home() {
    + {/* Historial tipo Google */} +
    +

    Historial Reciente

    +
      + {history.map((h, i) => ( +
    • + +
      +

      + {h.user} {h.action} {h.target} +

      +

      {h.time}

      +
      +
    • + ))} +
    +
    + {/* Últimas alertas */}

    Últimas Alertas

    diff --git a/src/pages/OperatorManagement.tsx b/src/pages/OperatorManagement.tsx deleted file mode 100644 index 8efbc7b..0000000 --- a/src/pages/OperatorManagement.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { useEffect, useState } from "react"; -import { - Plus, - Trash2, - Pencil, - RefreshCcw, - ChevronRight, - ChevronDown, -} from "lucide-react"; -import MaterialTable from "@material-table/core"; - -/* ================= TYPES ================= */ -interface Operator { - id: number; - loginName: string; - isSuperAdmin: boolean; - isDisabled: boolean; - userName: string; - cellPhone: string; - createdAt: string; -} - -interface Area { - id: number; - name: string; - operators: Operator[]; - children?: Area[]; -} - -/* ================= COMPONENT ================= */ -export default function OperatorManagement() { - const [areas, setAreas] = useState([]); - const [selectedArea, setSelectedArea] = useState(null); - const [expandedIds, setExpandedIds] = useState([]); - const [search, setSearch] = useState(""); - const [showModal, setShowModal] = useState(false); - const [editingId, setEditingId] = useState(null); - const [activeOperator, setActiveOperator] = useState(null); - - const emptyOperator: Omit = { - loginName: "", - isSuperAdmin: false, - isDisabled: false, - userName: "", - cellPhone: "", - createdAt: new Date().toISOString().slice(0, 10), - }; - - const [form, setForm] = useState>(emptyOperator); - - /* ================= DATA ================= */ - const loadData = () => { - const mock: Area[] = [ - { - id: 1, - name: "GRH", - operators: [ - { - id: 1, - loginName: "admin_grh", - isSuperAdmin: true, - isDisabled: false, - userName: "Juan Pérez", - cellPhone: "664-123-4567", - createdAt: "2024-01-10", - }, - ], - children: [ - { - id: 2, - name: "CESPT", - operators: [ - { - id: 2, - loginName: "cespt_admin", - isSuperAdmin: false, - isDisabled: false, - userName: "Carlos Ruiz", - cellPhone: "664-555-8899", - createdAt: "2024-02-02", - }, - ], - }, - ], - }, - ]; - - setAreas(mock); - setSelectedArea(mock[0]); - setExpandedIds([1]); - setActiveOperator(null); - }; - - useEffect(() => { - loadData(); - }, []); - - /* ================= TREE ================= */ - const toggleExpand = (id: number) => { - setExpandedIds((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] - ); - }; - - const updateArea = (list: Area[]): Area[] => - list.map((area) => { - if (area.id === selectedArea?.id) { - const operators = editingId - ? area.operators.map((op) => - op.id === editingId ? { ...op, ...form } : op - ) - : [...area.operators, { id: Date.now(), ...form }]; - - return { ...area, operators }; - } - if (area.children) { - return { ...area, children: updateArea(area.children) }; - } - return area; - }); - - /* ================= CRUD ================= */ - const handleSave = () => { - setAreas((prev) => { - const updated = updateArea(prev); - - // 🔑 volver a apuntar al área actual actualizada - const refreshedArea = - updated.find((a) => a.id === selectedArea?.id) || null; - - setSelectedArea(refreshedArea); - return updated; - }); - - setShowModal(false); - setEditingId(null); - setForm(emptyOperator); - }; - - const handleEdit = () => { - if (!activeOperator) return; - setEditingId(activeOperator.id); - setForm({ ...activeOperator }); - setShowModal(true); - }; - - const handleDelete = () => { - if (!selectedArea || !activeOperator) return; - - const deleteFromTree = (list: Area[]): Area[] => - list.map((area) => { - if (area.id === selectedArea.id) { - return { - ...area, - operators: area.operators.filter( - (op) => op.id !== activeOperator.id - ), - }; - } - if (area.children) { - return { ...area, children: deleteFromTree(area.children) }; - } - return area; - }); - - setAreas((prev) => deleteFromTree(prev)); - setActiveOperator(null); - }; - - /* ================= FILTER ================= */ - const filtered = - selectedArea?.operators.filter( - (op) => - op.loginName.toLowerCase().includes(search.toLowerCase()) || - op.userName.toLowerCase().includes(search.toLowerCase()) - ) || []; - - /* ================= TREE RENDER ================= */ - const renderTree = (area: Area, level = 0) => { - const expanded = expandedIds.includes(area.id); - - return ( -
    -
    setSelectedArea(area)} - > - {area.children && ( - - )} - {area.name} -
    - - {expanded && - area.children?.map((child) => renderTree(child, level + 1))} -
    - ); - }; - - /* ================= UI ================= */ - return ( -
    - {/* SIDEBAR */} -
    -

    - Organizational Structure -

    - {areas.map((a) => renderTree(a))} -
    - - {/* MAIN */} -
    - {/* HEADER */} -
    -
    -

    Operator Management

    -

    {selectedArea?.name}

    -
    - -
    - {/* ADD */} - - - {/* EDIT */} - - - {/* DELETE */} - - - {/* REFRESH */} - -
    -
    - - {/* SEARCH */} - setSearch(e.target.value)} - /> - - ( - - {rowData.isSuperAdmin ? "Yes" : "No"} - - ), - }, - { - title: "Status", - field: "isDisabled", - render: (rowData) => ( - - {rowData.isDisabled ? "Off" : "Active"} - - ), - }, - { title: "User", field: "userName" }, - { title: "Phone", field: "cellPhone" }, - { title: "Created", field: "createdAt", type: "date" }, - ]} - data={filtered} - onRowClick={(_event, rowData) => { - setActiveOperator(rowData as Operator); - }} - actions={[ - { - icon: () => , - tooltip: "Add Operator", - isFreeAction: true, - onClick: () => { - setForm(emptyOperator); - setEditingId(null); - setShowModal(true); - }, - }, - { - icon: () => , - tooltip: "Edit Operator", - onClick: (_event, rowData) => { - setActiveOperator(rowData as Operator); - setEditingId((rowData as Operator).id); - setForm({ ...(rowData as Operator) }); - setShowModal(true); - }, - }, - { - icon: () => , - tooltip: "Delete Operator", - onClick: (_event, rowData) => { - setActiveOperator(rowData as Operator); - handleDelete(); - }, - }, - ]} - options={{ - actionsColumnIndex: -1, - search: false, - paging: true, - sorting: true, - headerStyle: { - textAlign: "center", - fontWeight: 600, - }, - maxBodyHeight: "400px", - tableLayout: "fixed", - rowStyle: (rowData) => ({ - backgroundColor: - activeOperator?.id === (rowData as Operator).id - ? "#EEF2FF" - : "#FFFFFF", - }), - }} - /> -
    - - {/* MODAL */} - {showModal && ( -
    -
    -

    - {editingId ? "Edit Operator" : "Add Operator"} -

    - - setForm({ ...form, loginName: e.target.value })} - /> - - - - - - setForm({ ...form, userName: e.target.value })} - /> - - setForm({ ...form, cellPhone: e.target.value })} - /> - - setForm({ ...form, createdAt: e.target.value })} - /> - -
    - - -
    -
    -
    - )} -
    - ); -} diff --git a/src/pages/RolesPage.tsx b/src/pages/RolesPage.tsx new file mode 100644 index 0000000..3456838 --- /dev/null +++ b/src/pages/RolesPage.tsx @@ -0,0 +1,149 @@ +import { useState, useEffect } from "react"; +import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; +import MaterialTable from "@material-table/core"; + +export interface Role { + id: string; + name: string; + description: string; + status: "ACTIVE" | "INACTIVE"; + createdAt: string; +} + +export default function RolesPage() { + const initialRoles: Role[] = [ + { id: "1", name: "SUPER_ADMIN", description: "Full access", status: "ACTIVE", createdAt: "2025-12-17" }, + { id: "2", name: "USER", description: "Regular user", status: "ACTIVE", createdAt: "2025-12-16" }, + ]; + + const [roles, setRoles] = useState(initialRoles); + const [activeRole, setActiveRole] = useState(null); + const [search, setSearch] = useState(""); + + const [showModal, setShowModal] = useState(false); + const [editingId, setEditingId] = useState(null); + + const emptyRole: Omit = { + name: "", + description: "", + status: "ACTIVE", + createdAt: new Date().toISOString().slice(0, 10), + }; + + const [form, setForm] = useState>(emptyRole); + + const handleSave = () => { + if (editingId) { + setRoles(prev => prev.map(r => r.id === editingId ? { id: editingId, ...form } : r)); + } else { + const newId = Date.now().toString(); + setRoles(prev => [...prev, { id: newId, ...form }]); + } + setShowModal(false); + setEditingId(null); + setForm(emptyRole); + }; + + const handleDelete = () => { + if (!activeRole) return; + setRoles(prev => prev.filter(r => r.id !== activeRole.id)); + setActiveRole(null); + }; + + const filtered = roles.filter(r => r.name.toLowerCase().includes(search.toLowerCase())); + + return ( +
    + {/* LEFT INFO SIDEBAR */} +
    +

    Role Information

    +

    Aquí se listan los roles disponibles en el sistema.

    +
    + + {/* MAIN */} +
    + {/* HEADER */} +
    +
    +

    Role Management

    +

    Roles registrados

    +
    +
    + + + + +
    +
    + + {/* SEARCH */} + setSearch(e.target.value)} /> + + {/* TABLE */} + ( + + {rowData.status} + + ) + }, + { title: "Created", field: "createdAt", type: "date" } + ]} + data={filtered} + onRowClick={(_, rowData) => setActiveRole(rowData as Role)} + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ backgroundColor: activeRole?.id === (rowData as Role).id ? "#EEF2FF" : "#FFFFFF" }) + }} + /> +
    + + {/* MODAL */} + {showModal && ( +
    +
    +

    {editingId ? "Edit Role" : "Add Role"}

    + setForm({...form, name: e.target.value})} /> + setForm({...form, description: e.target.value})} /> + + setForm({...form, createdAt: e.target.value})} /> +
    + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx new file mode 100644 index 0000000..cc58ce0 --- /dev/null +++ b/src/pages/UsersPage.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from "react"; +import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; +import MaterialTable from "@material-table/core"; +import { Role } from "./RolesPage"; // Importa los tipos de roles + +interface User { + id: string; + name: string; + email: string; + roleId: string; + roleName: string; + status: "ACTIVE" | "INACTIVE"; + createdAt: string; +} + +export default function UsersPage() { + const initialRoles: Role[] = [ + { id: "1", name: "SUPER_ADMIN", description: "Full access", status: "ACTIVE", createdAt: "2025-12-17" }, + { id: "2", name: "USER", description: "Regular user", status: "ACTIVE", createdAt: "2025-12-16" }, + ]; + + const initialUsers: User[] = [ + { id: "1", name: "Admin GRH", email: "grh@domain.com", roleId: "1", roleName: "SUPER_ADMIN", status: "ACTIVE", createdAt: "2025-12-17" }, + { id: "2", name: "User CESPT", email: "cespt@domain.com", roleId: "2", roleName: "USER", status: "ACTIVE", createdAt: "2025-12-16" }, + ]; + + const [users, setUsers] = useState(initialUsers); + const [activeUser, setActiveUser] = useState(null); + const [search, setSearch] = useState(""); + const [showModal, setShowModal] = useState(false); + const [editingId, setEditingId] = useState(null); + const [roles, setRoles] = useState(initialRoles); + + const emptyUser: Omit = { name: "", email: "", roleId: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) }; + const [form, setForm] = useState>(emptyUser); + + const handleSave = () => { + const roleName = roles.find(r => r.id === form.roleId)?.name || ""; + if (editingId) { + setUsers(prev => prev.map(u => u.id === editingId ? { id: editingId, roleName, ...form } : u)); + } else { + const newId = Date.now().toString(); + setUsers(prev => [...prev, { id: newId, roleName, ...form }]); + } + setShowModal(false); + setEditingId(null); + setForm(emptyUser); + }; + + const handleDelete = () => { + if (!activeUser) return; + setUsers(prev => prev.filter(u => u.id !== activeUser.id)); + setActiveUser(null); + }; + + const filtered = users.filter(u => u.name.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase())); + + return ( +
    + {/* LEFT INFO SIDEBAR */} +
    +

    Project Information

    +

    Usuarios disponibles y sus roles.

    + +
    + + {/* MAIN */} +
    + {/* HEADER */} +
    +
    +

    User Management

    +

    Usuarios registrados

    +
    +
    + + + + +
    +
    + + {/* SEARCH */} + setSearch(e.target.value)} /> + + {/* TABLE */} + {rowData.status} }, + { title: "Created", field: "createdAt", type: "date" } + ]} + data={filtered} + onRowClick={(_, rowData) => setActiveUser(rowData as User)} + options={{ actionsColumnIndex: -1, search: false, paging: true, sorting: true, rowStyle: rowData => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" }) }} + /> +
    + + {/* MODAL */} + {showModal && ( +
    +
    +

    {editingId ? "Edit User" : "Add User"}

    + setForm({...form, name: e.target.value})} /> + setForm({...form, email: e.target.value})} /> + + + setForm({...form, createdAt: e.target.value})} /> +
    + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/pages/concentrators/ConcentratorsPage.tsx b/src/pages/concentrators/ConcentratorsPage.tsx new file mode 100644 index 0000000..ab9492d --- /dev/null +++ b/src/pages/concentrators/ConcentratorsPage.tsx @@ -0,0 +1,302 @@ +import { useState } from "react"; +import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; +import MaterialTable from "@material-table/core"; + +/* ================= TYPES ================= */ +interface Concentrator { + id: number; + name: string; + location: string; + status: "ACTIVE" | "INACTIVE"; + project: string; + createdAt: string; +} + +interface User { + name: string; + role: "SUPER_ADMIN" | "USER"; + project?: string; // asignado si no es superadmin +} + +/* ================= COMPONENT ================= */ +export default function ConcentratorsPage() { + // Simulación de usuario actual + const currentUser: User = { + name: "Admin GRH", + role: "SUPER_ADMIN", // cambiar a USER para probar otro caso + project: "CESPT", + }; + + // Lista de proyectos disponibles + const allProjects = ["GRH (PADRE)", "CESPT", "Proyecto A", "Proyecto B"]; + + // Proyectos visibles según el usuario + const visibleProjects = + currentUser.role === "SUPER_ADMIN" + ? allProjects + : currentUser.project + ? [currentUser.project] + : []; + + const [selectedProject, setSelectedProject] = useState( + visibleProjects[0] || "" + ); + + const [concentrators, setConcentrators] = useState([ + { + id: 1, + name: "Concentrador A", + location: "Planta 1", + status: "ACTIVE", + project: "GRH (PADRE)", + createdAt: "2025-12-17", + }, + { + id: 2, + name: "Concentrador B", + location: "Planta 2", + status: "INACTIVE", + project: "CESPT", + createdAt: "2025-12-16", + }, + { + id: 3, + name: "Concentrador C", + location: "Planta 3", + status: "ACTIVE", + project: "Proyecto A", + createdAt: "2025-12-15", + }, + ]); + + const [activeConcentrator, setActiveConcentrator] = useState(null); + const [search, setSearch] = useState(""); + + const [showModal, setShowModal] = useState(false); + const [editingId, setEditingId] = useState(null); + + const emptyConcentrator: Omit = { + name: "", + location: "", + status: "ACTIVE", + project: selectedProject, + createdAt: new Date().toISOString().slice(0, 10), + }; + + const [form, setForm] = useState>(emptyConcentrator); + + /* ================= CRUD ================= */ + const handleSave = () => { + if (editingId) { + setConcentrators((prev) => + prev.map((c) => + c.id === editingId ? { id: editingId, ...form } : c + ) + ); + } else { + const newId = Date.now(); + setConcentrators((prev) => [...prev, { id: newId, ...form }]); + } + setShowModal(false); + setEditingId(null); + setForm({ ...emptyConcentrator, project: selectedProject }); + setActiveConcentrator(null); + }; + + const handleDelete = () => { + if (!activeConcentrator) return; + setConcentrators((prev) => + prev.filter((c) => c.id !== activeConcentrator.id) + ); + setActiveConcentrator(null); + }; + + /* ================= FILTER ================= */ + const filtered = concentrators.filter( + (c) => + (c.name.toLowerCase().includes(search.toLowerCase()) || + c.location.toLowerCase().includes(search.toLowerCase())) && + c.project === selectedProject + ); + + /* ================= UI ================= */ + return ( +
    + {/* LEFT INFO SIDEBAR */} +
    +

    + Project Information +

    + + +
    + + {/* MAIN */} +
    + {/* HEADER */} +
    +
    +

    Concentrator Management

    +

    Concentradores registrados

    +
    + +
    + + + + + + + +
    +
    + + {/* SEARCH */} + setSearch(e.target.value)} + /> + + {/* TABLE */} + ( + + {rowData.status} + + ), + }, + { title: "Location", field: "location" }, + { title: "Project", field: "project" }, + { title: "Created", field: "createdAt", type: "date" }, + ]} + data={filtered} + onRowClick={(_, rowData) => setActiveConcentrator(rowData as Concentrator)} + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeConcentrator?.id === (rowData as Concentrator).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + /> +
    + + {/* MODAL */} + {showModal && ( +
    +
    +

    + {editingId ? "Edit Concentrator" : "Add Concentrator"} +

    + + setForm({ ...form, name: e.target.value })} + /> + + setForm({ ...form, location: e.target.value })} + /> + + + + setForm({ ...form, createdAt: e.target.value })} + /> + +
    + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/pages/concentrators/concentrators.api.ts b/src/pages/concentrators/concentrators.api.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/meters/MeterPage.tsx b/src/pages/meters/MeterPage.tsx new file mode 100644 index 0000000..5e4c1b9 --- /dev/null +++ b/src/pages/meters/MeterPage.tsx @@ -0,0 +1,315 @@ +import { useState } from "react"; +import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; +import MaterialTable from "@material-table/core"; + +/* ================= TYPES ================= */ +export interface Meter { + id: string; // recordId + serialNumber: string; + status: "ACTIVE" | "INACTIVE"; + project: string; + createdAt: string; +} + +interface User { + name: string; + role: "SUPER_ADMIN" | "USER"; + project?: string; // asignado si no es superadmin +} + +/* ================= COMPONENT ================= */ +export default function MeterManagement() { + // Simulación de usuario actual + const currentUser: User = { + name: "Admin GRH", + role: "SUPER_ADMIN", // cambiar a USER para probar otro caso + project: "CESPT", + }; + + // Lista de proyectos disponibles + const allProjects = ["GRH (PADRE)", "CESPT", "Proyecto A", "Proyecto B"]; + + // Proyectos visibles según el usuario + const visibleProjects = + currentUser.role === "SUPER_ADMIN" + ? allProjects + : currentUser.project + ? [currentUser.project] + : []; + + const [selectedProject, setSelectedProject] = useState( + visibleProjects[0] || "" + ); + + // Datos locales iniciales (simulan la API) + const initialMeters: Meter[] = [ + { + id: "1", + serialNumber: "SN001", + status: "ACTIVE", + project: "GRH (PADRE)", + createdAt: "2025-12-17", + }, + { + id: "2", + serialNumber: "SN002", + status: "INACTIVE", + project: "CESPT", + createdAt: "2025-12-16", + }, + { + id: "3", + serialNumber: "SN003", + status: "ACTIVE", + project: "Proyecto A", + createdAt: "2025-12-15", + }, + ]; + + const [meters, setMeters] = useState(initialMeters); + const [activeMeter, setActiveMeter] = useState(null); + const [search, setSearch] = useState(""); + + const [showModal, setShowModal] = useState(false); + const [editingId, setEditingId] = useState(null); + + const emptyMeter: Omit = { + serialNumber: "", + status: "ACTIVE", + project: selectedProject, + createdAt: new Date().toISOString().slice(0, 10), + }; + + const [form, setForm] = useState>(emptyMeter); + + /* ================= CRUD LOCAL ================= */ + const handleSave = () => { + if (editingId) { + setMeters((prev) => + prev.map((m) => + m.id === editingId ? { ...m, ...form } : m + ) + ); + } else { + const newMeter: Meter = { + id: (Math.random() * 1000000).toFixed(0), + ...form, + }; + setMeters((prev) => [...prev, newMeter]); + } + + setShowModal(false); + setEditingId(null); + setForm({ ...emptyMeter, project: selectedProject }); + setActiveMeter(null); + }; + + const handleDelete = () => { + if (!activeMeter) return; + setMeters((prev) => prev.filter((m) => m.id !== activeMeter.id)); + setActiveMeter(null); + }; + + const handleRefresh = () => { + // Simula recargar los datos originales + setMeters(initialMeters); + setActiveMeter(null); + }; + + /* ================= FILTER ================= */ + const filtered = meters.filter( + (m) => + (m.serialNumber.toLowerCase().includes(search.toLowerCase()) || + m.project.toLowerCase().includes(search.toLowerCase())) && + m.project === selectedProject + ); + + /* ================= UI ================= */ + return ( +
    + {/* LEFT INFO SIDEBAR */} +
    +

    + Project Information +

    + + +
    + + {/* MAIN */} +
    + {/* HEADER */} +
    +
    +

    Meter Management

    +

    Medidores registrados

    +
    + +
    + + + + + + + +
    +
    + + {/* SEARCH */} + setSearch(e.target.value)} + /> + + {/* TABLE */} + ( + + {rowData.status} + + ), + }, + { title: "Project", field: "project" }, + { title: "Created", field: "createdAt", type: "date" }, + ]} + data={filtered} + onRowClick={(_, rowData) => setActiveMeter(rowData as Meter)} + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeMeter?.id === (rowData as Meter).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + /> +
    + + {/* MODAL */} + {showModal && ( +
    +
    +

    + {editingId ? "Edit Meter" : "Add Meter"} +

    + + + setForm({ ...form, serialNumber: e.target.value }) + } + /> + + + + + setForm({ ...form, project: e.target.value }) + } + /> + + + setForm({ ...form, createdAt: e.target.value }) + } + /> + +
    + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/pages/meters/meters.tapi.ts b/src/pages/meters/meters.tapi.ts new file mode 100644 index 0000000..e69de29