diff --git a/src/App.tsx b/src/App.tsx index 6650b12..b54ae59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage"; import ProjectsPage from "./pages/projects/ProjectsPage"; import UsersPage from "./pages/UsersPage"; import RolesPage from "./pages/RolesPage"; +import ConsumptionPage from "./pages/consumption/ConsumptionPage"; import ProfileModal from "./components/layout/common/ProfileModal"; import { updateMyProfile } from "./api/me"; @@ -18,7 +19,15 @@ import SettingsModal, { import LoginPage from "./pages/LoginPage"; -// ✅ NUEVO +// Auth imports +import { + isAuthenticated, + getMe, + logout as authLogout, + clearAuth, + type AuthUser, +} from "./api/auth"; + import ConfirmModal from "./components/layout/common/ConfirmModal"; import Watermark from "./components/layout/common/Watermark"; @@ -27,28 +36,59 @@ export type Page = | "projects" | "meters" | "concentrators" + | "consumption" | "users" | "roles"; -const AUTH_KEY = "grh_auth"; - export default function App() { - const [isAuth, setIsAuth] = useState(() => { - return Boolean(localStorage.getItem(AUTH_KEY)); - }); + const [isAuth, setIsAuth] = useState(false); + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); - const handleLogin = (payload?: { token?: string }) => { - localStorage.setItem( - AUTH_KEY, - JSON.stringify({ token: payload?.token ?? "demo", ts: Date.now() }) - ); - setIsAuth(true); + // Check authentication on mount + useEffect(() => { + const checkAuth = async () => { + if (isAuthenticated()) { + try { + const userData = await getMe(); + setUser(userData); + setIsAuth(true); + } catch { + clearAuth(); + setIsAuth(false); + setUser(null); + } + } + setAuthLoading(false); + }; + checkAuth(); + }, []); + + const handleLogin = () => { + // After successful login, fetch user data + const fetchUser = async () => { + try { + const userData = await getMe(); + setUser(userData); + setIsAuth(true); + } catch { + clearAuth(); + setIsAuth(false); + } + }; + fetchUser(); }; - const handleLogout = () => { - localStorage.removeItem(AUTH_KEY); + const handleLogout = async () => { + try { + await authLogout(); + } catch { + // Ignore logout errors + } + clearAuth(); + setUser(null); setIsAuth(false); - // opcional: reset de navegación + // Reset navigation setPage("home"); setSubPage("default"); setSelectedProject(""); @@ -65,13 +105,6 @@ export default function App() { const [profileOpen, setProfileOpen] = useState(false); const [savingProfile, setSavingProfile] = useState(false); - const [user, setUser] = useState({ - name: "CESPT Admin", - email: "admin@cespt.gob.mx", - avatarUrl: null as string | null, - organismName: "CESPT", - }); - const [settingsOpen, setSettingsOpen] = useState(false); const [settings, setSettings] = useState(() => loadSettings()); @@ -84,7 +117,7 @@ export default function App() { const handleUploadAvatar = async (file: File) => { const base64 = await fileToBase64(file); localStorage.setItem("mock_avatar", base64); - setUser((prev) => ({ ...prev, avatarUrl: base64 })); + setUser((prev) => prev ? { ...prev, avatar_url: base64 } : null); }; function fileToBase64(file: File) { @@ -101,18 +134,17 @@ export default function App() { email: string; organismName?: string; }) => { + if (!user) return; setSavingProfile(true); try { const updated = await updateMyProfile(next); - setUser((prev) => ({ + setUser((prev) => prev ? ({ ...prev, name: updated.name ?? next.name ?? prev.name, email: updated.email ?? next.email ?? prev.email, - avatarUrl: updated.avatarUrl ?? prev.avatarUrl, - organismName: - updated.organismName ?? next.organismName ?? prev.organismName, - })); + avatar_url: updated.avatarUrl ?? prev.avatar_url, + }) : null); setProfileOpen(false); } finally { @@ -141,6 +173,8 @@ export default function App() { return ; case "concentrators": return ; + case "consumption": + return ; case "users": return ; case "roles": @@ -159,6 +193,15 @@ export default function App() { } }; + // Show loading while checking authentication + if (authLoading) { + return ( +
+
Cargando...
+
+ ); + } + if (!isAuth) { return ; } @@ -186,11 +229,10 @@ export default function App() { page={page} subPage={subPage} setSubPage={setSubPage} - userName={user.name} - userEmail={user.email} - avatarUrl={user.avatarUrl} + userName={user?.name ?? "Usuario"} + userEmail={user?.email ?? ""} + avatarUrl={user?.avatar_url ?? null} onOpenProfile={() => setProfileOpen(true)} - // ✅ en vez de cerrar, abrimos confirm modal onRequestLogout={() => setLogoutOpen(true)} /> @@ -212,11 +254,11 @@ export default function App() { setProfileOpen(false)} onSave={handleSaveProfile} @@ -236,7 +278,7 @@ export default function App() { onConfirm={async () => { setLoggingOut(true); try { - handleLogout(); + await handleLogout(); setLogoutOpen(false); } finally { setLoggingOut(false); diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..1c01996 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,331 @@ +/** + * Authentication API Module + * Handles login, logout, token refresh, and user session management + */ + +import { apiClient } from './client'; +import { ApiError } from './types'; + +// Storage keys for authentication tokens +const ACCESS_TOKEN_KEY = 'grh_access_token'; +const REFRESH_TOKEN_KEY = 'grh_refresh_token'; + +/** + * Login credentials interface + */ +export interface LoginCredentials { + email: string; + password: string; +} + +/** + * Authentication tokens interface + */ +export interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +/** + * Authenticated user interface + */ +export interface AuthUser { + id: string; + email: string; + name: string; + role: string; + avatar_url?: string; +} + +/** + * Login response combining tokens and user data + */ +export interface LoginResponse extends AuthTokens { + user: AuthUser; +} + +/** + * Refresh token response + */ +export interface RefreshResponse { + accessToken: string; +} + +/** + * Store authentication tokens in localStorage + * @param tokens - The tokens to store + */ +function storeTokens(tokens: AuthTokens): void { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken); + } catch (error) { + console.error('Failed to store authentication tokens:', error); + } +} + +/** + * Authenticate user with email and password + * @param credentials - The login credentials + * @returns Promise resolving to tokens and user data + */ +export async function login(credentials: LoginCredentials): Promise { + // Validate credentials + if (!credentials.email || !credentials.password) { + throw new ApiError('Email and password are required', 400); + } + + const response = await apiClient.post( + '/api/auth/login', + credentials, + { skipAuth: true } + ); + + // Store tokens on successful login + if (response.accessToken && response.refreshToken) { + storeTokens({ + accessToken: response.accessToken, + refreshToken: response.refreshToken, + }); + } + + return response; +} + +/** + * Refresh the access token using the stored refresh token + * @returns Promise resolving to the new access token + */ +export async function refresh(): Promise { + const refreshToken = getStoredTokens()?.refreshToken; + + if (!refreshToken) { + throw new ApiError('No refresh token available', 401); + } + + const response = await apiClient.post( + '/api/auth/refresh', + { refreshToken }, + { skipAuth: true } + ); + + // Update stored access token + if (response.accessToken) { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); + } catch (error) { + console.error('Failed to update access token:', error); + } + } + + return response; +} + +/** + * Log out the current user + * Clears tokens and optionally notifies the server + * @returns Promise resolving when logout is complete + */ +export async function logout(): Promise { + try { + const refreshToken = getRefreshToken(); + // Attempt to notify server about logout + // This allows the server to invalidate the refresh token + await apiClient.post('/api/auth/logout', { refreshToken }, { + skipAuth: false, // Include token so server knows which session to invalidate + }); + } catch (error) { + // Continue with local logout even if server request fails + console.warn('Server logout request failed:', error); + } finally { + // Always clear local tokens + clearAuth(); + } +} + +/** + * Get the currently authenticated user's profile + * @returns Promise resolving to the user data + */ +export async function getMe(): Promise { + return apiClient.get('/api/auth/me'); +} + +/** + * Check if the user is currently authenticated + * Validates that an access token exists and is not obviously expired + * @returns boolean indicating authentication status + */ +export function isAuthenticated(): boolean { + const tokens = getStoredTokens(); + + if (!tokens?.accessToken) { + return false; + } + + // Check if the token is a valid JWT and not expired + try { + const payload = parseJwtPayload(tokens.accessToken); + + if (!payload) { + return false; + } + + // Check if token has expired + if (payload.exp) { + const expirationTime = (payload.exp as number) * 1000; // Convert to milliseconds + const currentTime = Date.now(); + + // Consider token expired if less than 30 seconds remaining + if (currentTime >= expirationTime - 30000) { + return false; + } + } + + return true; + } catch { + // If we can't parse the token, assume it's invalid + return false; + } +} + +/** + * Get stored authentication tokens + * @returns The stored tokens or null if not found + */ +export function getStoredTokens(): AuthTokens | null { + try { + const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY); + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + + if (!accessToken || !refreshToken) { + return null; + } + + return { + accessToken, + refreshToken, + }; + } catch { + return null; + } +} + +/** + * Clear all authentication data from storage + */ +export function clearAuth(): void { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch (error) { + console.error('Failed to clear authentication data:', error); + } +} + +/** + * Parse JWT payload without verification + * Used for client-side token inspection (expiration check, etc.) + * @param token - The JWT token to parse + * @returns The parsed payload or null if invalid + */ +function parseJwtPayload(token: string): Record | null { + try { + const parts = token.split('.'); + + if (parts.length !== 3) { + return null; + } + + const payload = parts[1]; + + // Handle base64url encoding + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + + // Pad with '=' if necessary + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + + const decoded = atob(padded); + return JSON.parse(decoded); + } catch { + return null; + } +} + +/** + * Get the access token for external use + * Useful when other parts of the app need the raw token + * @returns The access token or null + */ +export function getAccessToken(): string | null { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY); + } catch { + return null; + } +} + +/** + * Get the refresh token for external use + * @returns The refresh token or null + */ +export function getRefreshToken(): string | null { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } +} + +/** + * Check if the access token is about to expire (within threshold) + * @param thresholdMs - Time threshold in milliseconds (default: 5 minutes) + * @returns boolean indicating if token is expiring soon + */ +export function isTokenExpiringSoon(thresholdMs: number = 5 * 60 * 1000): boolean { + const tokens = getStoredTokens(); + + if (!tokens?.accessToken) { + return true; + } + + try { + const payload = parseJwtPayload(tokens.accessToken); + + if (!payload?.exp) { + return false; // Can't determine expiration, assume it's fine + } + + const expirationTime = (payload.exp as number) * 1000; + const currentTime = Date.now(); + + return currentTime >= expirationTime - thresholdMs; + } catch { + return true; + } +} + +/** + * Update user profile + * @param updates - The profile updates + * @returns Promise resolving to the updated user data + */ +export async function updateProfile(updates: Partial>): Promise { + return apiClient.patch('/api/auth/me', updates); +} + +/** + * Change user password + * @param currentPassword - The current password + * @param newPassword - The new password + * @returns Promise resolving when password is changed + */ +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise { + await apiClient.post('/api/auth/change-password', { + currentPassword, + newPassword, + }); +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..be9c697 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,386 @@ +/** + * API Client with JWT Authentication + * Handles all HTTP requests with automatic token management + */ + +import { ApiError } from './types'; + +// Storage keys for authentication tokens +const ACCESS_TOKEN_KEY = 'grh_access_token'; +const REFRESH_TOKEN_KEY = 'grh_refresh_token'; + +// Base URL from environment variable +const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +/** + * Request configuration options + */ +interface RequestOptions { + headers?: Record; + params?: Record; + skipAuth?: boolean; +} + +/** + * Internal request configuration + */ +interface InternalRequestConfig { + method: string; + url: string; + data?: unknown; + options?: RequestOptions; +} + +/** + * Flag to prevent multiple simultaneous refresh attempts + */ +let isRefreshing = false; + +/** + * Queue of requests waiting for token refresh + */ +let refreshQueue: Array<{ + resolve: (token: string) => void; + reject: (error: Error) => void; +}> = []; + +/** + * Get stored access token from localStorage + */ +function getAccessToken(): string | null { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY); + } catch { + return null; + } +} + +/** + * Get stored refresh token from localStorage + */ +function getRefreshToken(): string | null { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } +} + +/** + * Store access token in localStorage + */ +function setAccessToken(token: string): void { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + } catch { + console.error('Failed to store access token'); + } +} + +/** + * Clear all auth tokens from localStorage + */ +function clearTokens(): void { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + console.error('Failed to clear tokens'); + } +} + +/** + * Redirect to login page + */ +function redirectToLogin(): void { + clearTokens(); + // Use window.location for a hard redirect to clear any state + window.location.href = '/login'; +} + +/** + * Build URL with query parameters + */ +function buildUrl(endpoint: string, params?: RequestOptions['params']): string { + const url = new URL(endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)); + } + }); + } + + return url.toString(); +} + +/** + * Build headers with optional authentication + */ +function buildHeaders(options?: RequestOptions): Headers { + const headers = new Headers({ + 'Content-Type': 'application/json', + ...options?.headers, + }); + + if (!options?.skipAuth) { + const token = getAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + } + + return headers; +} + +/** + * Attempt to refresh the access token + */ +async function refreshAccessToken(): Promise { + const refreshToken = getRefreshToken(); + + if (!refreshToken) { + throw new ApiError('No refresh token available', 401); + } + + const response = await fetch(`${BASE_URL}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + errorData.error?.message || 'Token refresh failed', + response.status, + errorData.error?.errors + ); + } + + const data = await response.json(); + const newAccessToken = data.accessToken || data.data?.accessToken; + + if (!newAccessToken) { + throw new ApiError('Invalid refresh response', 401); + } + + setAccessToken(newAccessToken); + return newAccessToken; +} + +/** + * Handle token refresh with queue management + * Ensures only one refresh request is made at a time + */ +async function handleTokenRefresh(): Promise { + if (isRefreshing) { + // Wait for the ongoing refresh to complete + return new Promise((resolve, reject) => { + refreshQueue.push({ resolve, reject }); + }); + } + + isRefreshing = true; + + try { + const newToken = await refreshAccessToken(); + + // Resolve all queued requests with the new token + refreshQueue.forEach(({ resolve }) => resolve(newToken)); + refreshQueue = []; + + return newToken; + } catch (error) { + // Reject all queued requests + refreshQueue.forEach(({ reject }) => reject(error as Error)); + refreshQueue = []; + + throw error; + } finally { + isRefreshing = false; + } +} + +/** + * Parse response and handle errors + */ +async function parseResponse(response: Response): Promise { + const contentType = response.headers.get('content-type'); + + // Handle empty responses + if (response.status === 204 || !contentType) { + return undefined as T; + } + + // Parse JSON response + if (contentType.includes('application/json')) { + const data = await response.json(); + + // Handle wrapped API responses + if (data && typeof data === 'object') { + if ('success' in data) { + if (data.success === false) { + throw new ApiError( + data.error?.message || 'Request failed', + response.status, + data.error?.errors + ); + } + return data.data as T; + } + } + + return data as T; + } + + // Handle text responses + const text = await response.text(); + return text as T; +} + +/** + * Core request function with retry logic for 401 errors + */ +async function request(config: InternalRequestConfig): Promise { + const { method, url, data, options } = config; + + const makeRequest = async (authToken?: string): Promise => { + const headers = buildHeaders(options); + + // Override with new token if provided (after refresh) + if (authToken) { + headers.set('Authorization', `Bearer ${authToken}`); + } + + const fetchOptions: RequestInit = { + method, + headers, + }; + + if (data !== undefined && method !== 'GET' && method !== 'HEAD') { + fetchOptions.body = JSON.stringify(data); + } + + return fetch(buildUrl(url, options?.params), fetchOptions); + }; + + try { + let response = await makeRequest(); + + // Handle 401 Unauthorized - attempt token refresh + if (response.status === 401 && !options?.skipAuth) { + try { + const newToken = await handleTokenRefresh(); + // Retry the original request with new token + response = await makeRequest(newToken); + } catch (refreshError) { + // Refresh failed - redirect to login + redirectToLogin(); + throw new ApiError('Session expired. Please log in again.', 401); + } + } + + // Handle other error responses + if (!response.ok) { + let errorMessage = `Request failed with status ${response.status}`; + let errors: string[] | undefined; + + try { + const errorData = await response.json(); + if (errorData.error?.message) { + errorMessage = errorData.error.message; + errors = errorData.error.errors; + } else if (errorData.message) { + errorMessage = errorData.message; + } + } catch { + // Unable to parse error response, use default message + } + + throw new ApiError(errorMessage, response.status, errors); + } + + return parseResponse(response); + } catch (error) { + // Re-throw ApiError instances + if (error instanceof ApiError) { + throw error; + } + + // Handle network errors + if (error instanceof TypeError && error.message.includes('fetch')) { + throw new ApiError('Network error. Please check your connection.', 0); + } + + // Handle other errors + throw new ApiError( + error instanceof Error ? error.message : 'An unexpected error occurred', + 0 + ); + } +} + +/** + * API Client object with HTTP methods + */ +export const apiClient = { + /** + * Perform a GET request + * @param url - The endpoint URL + * @param options - Optional request configuration + * @returns Promise resolving to the response data + */ + get(url: string, options?: RequestOptions): Promise { + return request({ method: 'GET', url, options }); + }, + + /** + * Perform a POST request + * @param url - The endpoint URL + * @param data - The request body data + * @param options - Optional request configuration + * @returns Promise resolving to the response data + */ + post(url: string, data?: unknown, options?: RequestOptions): Promise { + return request({ method: 'POST', url, data, options }); + }, + + /** + * Perform a PUT request + * @param url - The endpoint URL + * @param data - The request body data + * @param options - Optional request configuration + * @returns Promise resolving to the response data + */ + put(url: string, data?: unknown, options?: RequestOptions): Promise { + return request({ method: 'PUT', url, data, options }); + }, + + /** + * Perform a PATCH request + * @param url - The endpoint URL + * @param data - The request body data + * @param options - Optional request configuration + * @returns Promise resolving to the response data + */ + patch(url: string, data?: unknown, options?: RequestOptions): Promise { + return request({ method: 'PATCH', url, data, options }); + }, + + /** + * Perform a DELETE request + * @param url - The endpoint URL + * @param data - Optional request body data + * @param options - Optional request configuration + * @returns Promise resolving to the response data + */ + delete(url: string, data?: unknown, options?: RequestOptions): Promise { + return request({ method: 'DELETE', url, data, options }); + }, +}; + +export default apiClient; diff --git a/src/api/concentrators.ts b/src/api/concentrators.ts index 0af1d6f..2660787 100644 --- a/src/api/concentrators.ts +++ b/src/api/concentrators.ts @@ -1,210 +1,139 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; -export const CONCENTRATORS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/mheif1vdgnyt8x2/records`; -const API_TOKEN = import.meta.env.VITE_API_TOKEN; +/** + * Concentrators API + * Handles all concentrator-related API operations using the backend API client + */ -const getAuthHeaders = () => ({ - Authorization: `Bearer ${API_TOKEN}`, - "Content-Type": "application/json", -}); +import { apiClient } from './client'; -export interface ConcentratorRecord { - id: string; - fields: { - "Area Name": string; - "Device S/N": string; - "Device Name": string; - "Device Time": string; - "Device Status": string; - "Operator": string; - "Installed Time": string; - "Communication Time": string; - "Instruction Manual": string; - }; +// Helper to convert snake_case to camelCase +function snakeToCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } -export interface ConcentratorsResponse { - records: ConcentratorRecord[]; - next?: string; - prev?: string; - nestedNext?: string; - nestedPrev?: string; +// Transform object keys from snake_case to camelCase +function transformKeys(obj: Record): T { + const transformed: Record = {}; + for (const key in obj) { + const camelKey = snakeToCamel(key); + const value = obj[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + transformed[camelKey] = transformKeys(value as Record); + } else { + transformed[camelKey] = value; + } + } + return transformed as T; } +// Transform array of objects +function transformArray(arr: Record[]): T[] { + return arr.map(item => transformKeys(item)); +} + +/** + * Concentrator type enum + */ +export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES'; + +/** + * Concentrator entity from the backend + */ export interface Concentrator { id: string; - "Area Name": string; - "Device S/N": string; - "Device Name": string; - "Device Time": string; - "Device Status": string; - "Operator": string; - "Installed Time": string; - "Communication Time": string; - "Instruction Manual": string; + serialNumber: string; + name: string; + projectId: string; + location: string | null; + type: ConcentratorType; + status: string; + ipAddress: string | null; + firmwareVersion: string | null; + lastCommunication: string | null; + createdAt: string; + updatedAt: string; } -export const fetchConcentrators = async (): Promise => { - try { - const url = new URL(CONCENTRATORS_API_URL); - url.searchParams.set('viewId', 'vw93mj98ylyxratm'); +/** + * Input data for creating or updating a concentrator + */ +export interface ConcentratorInput { + serialNumber: string; + name: string; + projectId: string; + location?: string; + type?: ConcentratorType; + status?: string; + ipAddress?: string; + firmwareVersion?: string; +} - - const response = await fetch(url.toString(), { - method: "GET", - headers: getAuthHeaders(), - }); +/** + * Fetch all concentrators, optionally filtered by project + * @param projectId - Optional project ID to filter concentrators + * @returns Promise resolving to an array of concentrators + */ +export async function fetchConcentrators(projectId?: string): Promise { + const params = projectId ? { project_id: projectId } : undefined; + const response = await apiClient.get[]>('/api/concentrators', { params }); + return transformArray(response); +} - if (!response.ok) { - throw new Error("Failed to fetch concentrators"); - } +/** + * Fetch a single concentrator by ID + * @param id - The concentrator ID + * @returns Promise resolving to the concentrator + */ +export async function fetchConcentrator(id: string): Promise { + const response = await apiClient.get>(`/api/concentrators/${id}`); + return transformKeys(response); +} - const data: ConcentratorsResponse = await response.json(); +/** + * Create a new concentrator + * @param data - The concentrator data + * @returns Promise resolving to the created concentrator + */ +export async function createConcentrator(data: ConcentratorInput): Promise { + const backendData = { + serial_number: data.serialNumber, + name: data.name, + project_id: data.projectId, + location: data.location, + type: data.type, + status: data.status, + ip_address: data.ipAddress, + firmware_version: data.firmwareVersion, + }; + const response = await apiClient.post>('/api/concentrators', backendData); + return transformKeys(response); +} - return data.records.map((r: ConcentratorRecord) => ({ - id: r.id, - "Area Name": r.fields["Area Name"] || "", - "Device S/N": r.fields["Device S/N"] || "", - "Device Name": r.fields["Device Name"] || "", - "Device Time": r.fields["Device Time"] || "", - "Device Status": r.fields["Device Status"] || "", - "Operator": r.fields["Operator"] || "", - "Installed Time": r.fields["Installed Time"] || "", - "Communication Time": r.fields["Communication Time"] || "", - "Instruction Manual": r.fields["Instruction Manual"] || "", - })); - } catch (error) { - console.error("Error fetching concentrators:", error); - throw error; - } -}; +/** + * Update an existing concentrator + * @param id - The concentrator ID + * @param data - The updated concentrator data + * @returns Promise resolving to the updated concentrator + */ +export async function updateConcentrator(id: string, data: Partial): Promise { + const backendData: Record = {}; + if (data.serialNumber !== undefined) backendData.serial_number = data.serialNumber; + if (data.name !== undefined) backendData.name = data.name; + if (data.projectId !== undefined) backendData.project_id = data.projectId; + if (data.location !== undefined) backendData.location = data.location; + if (data.type !== undefined) backendData.type = data.type; + if (data.status !== undefined) backendData.status = data.status; + if (data.ipAddress !== undefined) backendData.ip_address = data.ipAddress; + if (data.firmwareVersion !== undefined) backendData.firmware_version = data.firmwareVersion; -export const createConcentrator = async ( - concentratorData: Omit -): Promise => { - try { - const response = await fetch(CONCENTRATORS_API_URL, { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - fields: { - "Area Name": concentratorData["Area Name"], - "Device S/N": concentratorData["Device S/N"], - "Device Name": concentratorData["Device Name"], - "Device Time": concentratorData["Device Time"], - "Device Status": concentratorData["Device Status"], - "Operator": concentratorData["Operator"], - "Installed Time": concentratorData["Installed Time"], - "Communication Time": concentratorData["Communication Time"], - "Instruction Manual": concentratorData["Instruction Manual"], - }, - }), - }); + const response = await apiClient.patch>(`/api/concentrators/${id}`, backendData); + return transformKeys(response); +} - if (!response.ok) { - throw new Error(`Failed to create concentrator: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - const createdRecord = data.records?.[0]; - - if (!createdRecord) { - throw new Error("Invalid response format: no record returned"); - } - - return { - id: createdRecord.id, - "Area Name": createdRecord.fields["Area Name"] || concentratorData["Area Name"], - "Device S/N": createdRecord.fields["Device S/N"] || concentratorData["Device S/N"], - "Device Name": createdRecord.fields["Device Name"] || concentratorData["Device Name"], - "Device Time": createdRecord.fields["Device Time"] || concentratorData["Device Time"], - "Device Status": createdRecord.fields["Device Status"] || concentratorData["Device Status"], - "Operator": createdRecord.fields["Operator"] || concentratorData["Operator"], - "Installed Time": createdRecord.fields["Installed Time"] || concentratorData["Installed Time"], - "Communication Time": createdRecord.fields["Communication Time"] || concentratorData["Communication Time"], - "Instruction Manual": createdRecord.fields["Instruction Manual"] || concentratorData["Instruction Manual"], - }; - } catch (error) { - console.error("Error creating concentrator:", error); - throw error; - } -}; - -export const updateConcentrator = async ( - id: string, - concentratorData: Omit -): Promise => { - try { - const response = await fetch(CONCENTRATORS_API_URL, { - method: "PATCH", - headers: getAuthHeaders(), - body: JSON.stringify({ - id: id, - fields: { - "Area Name": concentratorData["Area Name"], - "Device S/N": concentratorData["Device S/N"], - "Device Name": concentratorData["Device Name"], - "Device Time": concentratorData["Device Time"], - "Device Status": concentratorData["Device Status"], - "Operator": concentratorData["Operator"], - "Installed Time": concentratorData["Installed Time"], - "Communication Time": concentratorData["Communication Time"], - "Instruction Manual": concentratorData["Instruction Manual"], - }, - }), - }); - - if (!response.ok) { - if (response.status === 400) { - const errorData = await response.json(); - throw new Error(`Bad Request: ${errorData.msg || "Invalid data provided"}`); - } - throw new Error(`Failed to update concentrator: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - const updatedRecord = data.records?.[0]; - - if (!updatedRecord) { - throw new Error("Invalid response format: no record returned"); - } - - return { - id: updatedRecord.id, - "Area Name": updatedRecord.fields["Area Name"] || concentratorData["Area Name"], - "Device S/N": updatedRecord.fields["Device S/N"] || concentratorData["Device S/N"], - "Device Name": updatedRecord.fields["Device Name"] || concentratorData["Device Name"], - "Device Time": updatedRecord.fields["Device Time"] || concentratorData["Device Time"], - "Device Status": updatedRecord.fields["Device Status"] || concentratorData["Device Status"], - "Operator": updatedRecord.fields["Operator"] || concentratorData["Operator"], - "Installed Time": updatedRecord.fields["Installed Time"] || concentratorData["Installed Time"], - "Communication Time": updatedRecord.fields["Communication Time"] || concentratorData["Communication Time"], - "Instruction Manual": updatedRecord.fields["Instruction Manual"] || concentratorData["Instruction Manual"], - }; - } catch (error) { - console.error("Error updating concentrator:", error); - throw error; - } -}; - -export const deleteConcentrator = async (id: string): Promise => { - try { - const response = await fetch(CONCENTRATORS_API_URL, { - method: "DELETE", - headers: getAuthHeaders(), - body: JSON.stringify({ - id: id, - }), - }); - - if (!response.ok) { - if (response.status === 400) { - const errorData = await response.json(); - throw new Error(`Bad Request: ${errorData.msg || "Invalid data provided"}`); - } - throw new Error(`Failed to delete concentrator: ${response.status} ${response.statusText}`); - } - } catch (error) { - console.error("Error deleting concentrator:", error); - throw error; - } -}; +/** + * Delete a concentrator + * @param id - The concentrator ID + * @returns Promise resolving when the concentrator is deleted + */ +export async function deleteConcentrator(id: string): Promise { + return apiClient.delete(`/api/concentrators/${id}`); +} diff --git a/src/api/me.ts b/src/api/me.ts index 2313db9..4bdf6d4 100644 --- a/src/api/me.ts +++ b/src/api/me.ts @@ -1,45 +1,83 @@ +/** + * User Profile API + * Handles all user profile-related API operations using the backend API client + */ + +import { apiClient } from './client'; + +/** + * User entity from the backend + */ +export interface User { + id: string; + email: string; + name: string; + avatarUrl: string | null; + role: string; + createdAt: string; + updatedAt: string; +} + +/** + * Get the current user's profile + * @returns Promise resolving to the user profile + */ +export async function getMyProfile(): Promise { + return apiClient.get('/api/me'); +} + +/** + * Update the current user's profile + * @param data - The updated profile data + * @returns Promise resolving to the updated user profile + */ +export async function updateMyProfile(data: { name?: string; email?: string }): Promise { + return apiClient.put('/api/me', data); +} + +/** + * Upload a new avatar for the current user + * @param file - The avatar image file + * @returns Promise resolving to the new avatar URL + */ export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> { - const form = new FormData(); - form.append("avatar", file); - - const res = await fetch("/api/me/avatar", { - method: "POST", - body: form, - // NO pongas Content-Type; el browser lo agrega con boundary - headers: { - // Si usas token: - // Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`, - }, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Upload avatar failed: ${res.status} ${text}`); - } - - const data = await res.json(); - if (!data?.avatarUrl) throw new Error("Respuesta sin avatarUrl"); - return { avatarUrl: data.avatarUrl }; + // For file uploads, we need to use FormData and handle it differently + const formData = new FormData(); + formData.append('avatar', file); + + const token = localStorage.getItem('grh_access_token'); + + const response = await fetch('/api/me/avatar', { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`Upload avatar failed: ${response.status} ${errorText}`); } - - export async function updateMyProfile(input: { - name: string; - email: string; - }): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> { - const res = await fetch("/api/me", { - method: "PUT", - headers: { - "Content-Type": "application/json", - // Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`, - }, - body: JSON.stringify(input), - }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Update profile failed: ${res.status} ${text}`); - } - - return res.json(); + + const data = await response.json(); + + if (!data?.avatarUrl && !data?.data?.avatarUrl) { + throw new Error('Response missing avatarUrl'); } - \ No newline at end of file + + return { avatarUrl: data.avatarUrl || data.data?.avatarUrl }; +} + +/** + * Change the current user's password + * @param currentPassword - The current password + * @param newPassword - The new password + * @returns Promise resolving when the password is changed + */ +export async function changeMyPassword(currentPassword: string, newPassword: string): Promise { + return apiClient.post('/api/me/password', { + currentPassword, + newPassword, + }); +} diff --git a/src/api/meters.ts b/src/api/meters.ts index 3d78d11..a972bc4 100644 --- a/src/api/meters.ts +++ b/src/api/meters.ts @@ -1,312 +1,238 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; -export const METERS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/m4hzpnopjkppaav/records`; -const API_TOKEN = import.meta.env.VITE_API_TOKEN; +/** + * Meters API + * Handles all meter-related API operations using the backend API client + */ -const getAuthHeaders = () => ({ - Authorization: `Bearer ${API_TOKEN}`, - "Content-Type": "application/json", -}); +import { apiClient } from './client'; -export interface MeterRecord { +// Helper to convert snake_case to camelCase +function snakeToCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +// Transform object keys from snake_case to camelCase +function transformKeys(obj: Record): T { + const transformed: Record = {}; + for (const key in obj) { + const camelKey = snakeToCamel(key); + const value = obj[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + transformed[camelKey] = transformKeys(value as Record); + } else { + transformed[camelKey] = value; + } + } + return transformed as T; +} + +// Transform array of objects +function transformArray(arr: Record[]): T[] { + return arr.map(item => transformKeys(item)); +} + +/** + * Meter entity from the backend + * Meters belong to Concentrators (LORA protocol) + */ +export interface Meter { id: string; - fields: { - CreatedAt: string; - UpdatedAt: string; - "Area Name": string; - "Account Number": string | null; - "User Name": string | null; - "User Address": string | null; - "Meter S/N": string; - "Meter Name": string; - "Meter Status": string; - "Protocol Type": string; - "Price No.": string | null; - "Price Name": string | null; - "DMA Partition": string | null; - "Supply Types": string; - "Device ID": string; - "Device Name": string; - "Device Type": string; - "Usage Analysis Type": string; - "installed Time": string; + serialNumber: string; + meterId: string | null; + name: string; + concentratorId: string; + location: string | null; + type: string; + status: string; + lastReadingValue: number | null; + lastReadingAt: string | null; + installationDate: string | null; + createdAt: string; + updatedAt: string; + // From joins + concentratorName?: string; + concentratorSerial?: string; + projectId?: string; + projectName?: string; +} + +/** + * Input data for creating or updating a meter + */ +export interface MeterInput { + serialNumber: string; + meterId?: string; + name: string; + concentratorId: string; + location?: string; + type?: string; + status?: string; + installationDate?: string; +} + +/** + * Meter reading entity + */ +export interface MeterReading { + id: string; + meterId: string; + value: number; + unit: string; + readingType: string; + readAt: string; + createdAt: string; +} + +/** + * Fetch all meters, optionally filtered by concentrator or project + * @param filters - Optional filters (concentratorId, projectId) + * @returns Promise resolving to an array of meters + */ +export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise { + const params: Record = {}; + if (filters?.concentratorId) params.concentrator_id = filters.concentratorId; + if (filters?.projectId) params.project_id = filters.projectId; + + const response = await apiClient.get[]>('/api/meters', { + params: Object.keys(params).length > 0 ? params : undefined + }); + return transformArray(response); +} + +/** + * Fetch a single meter by ID + * @param id - The meter ID + * @returns Promise resolving to the meter + */ +export async function fetchMeter(id: string): Promise { + const response = await apiClient.get>(`/api/meters/${id}`); + return transformKeys(response); +} + +/** + * Create a new meter + * @param data - The meter data + * @returns Promise resolving to the created meter + */ +export async function createMeter(data: MeterInput): Promise { + // Convert camelCase to snake_case for backend + const backendData = { + serial_number: data.serialNumber, + meter_id: data.meterId, + name: data.name, + concentrator_id: data.concentratorId, + location: data.location, + type: data.type, + status: data.status, + installation_date: data.installationDate, + }; + const response = await apiClient.post>('/api/meters', backendData); + return transformKeys(response); +} + +/** + * Update an existing meter + * @param id - The meter ID + * @param data - The updated meter data + * @returns Promise resolving to the updated meter + */ +export async function updateMeter(id: string, data: Partial): Promise { + // Convert camelCase to snake_case for backend + const backendData: Record = {}; + if (data.serialNumber !== undefined) backendData.serial_number = data.serialNumber; + if (data.meterId !== undefined) backendData.meter_id = data.meterId; + if (data.name !== undefined) backendData.name = data.name; + if (data.concentratorId !== undefined) backendData.concentrator_id = data.concentratorId; + if (data.location !== undefined) backendData.location = data.location; + if (data.type !== undefined) backendData.type = data.type; + if (data.status !== undefined) backendData.status = data.status; + if (data.installationDate !== undefined) backendData.installation_date = data.installationDate; + + const response = await apiClient.patch>(`/api/meters/${id}`, backendData); + return transformKeys(response); +} + +/** + * Delete a meter + * @param id - The meter ID + * @returns Promise resolving when the meter is deleted + */ +export async function deleteMeter(id: string): Promise { + return apiClient.delete(`/api/meters/${id}`); +} + +/** + * Fetch readings for a specific meter + * @param id - The meter ID + * @returns Promise resolving to an array of meter readings + */ +export async function fetchMeterReadings(id: string): Promise { + return apiClient.get(`/api/meters/${id}/readings`); +} + +/** + * Bulk upload result interface + */ +export interface BulkUploadResult { + success: boolean; + data: { + totalRows: number; + inserted: number; + failed: number; + errors: Array<{ + row: number; + error: string; + data?: Record; + }>; }; } -export interface MetersResponse { - records: MeterRecord[]; - next?: string; - prev?: string; - nestedNext?: string; - nestedPrev?: string; +/** + * Bulk upload meters from Excel file + * @param file - Excel file to upload + * @returns Promise resolving to upload result + */ +export async function bulkUploadMeters(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Error en la carga masiva'); + } + + return response.json(); } -export interface Meter { - id: string; - createdAt: string; - updatedAt: string; - areaName: string; - accountNumber: string | null; - userName: string | null; - userAddress: string | null; - meterSerialNumber: string; - meterName: string; - meterStatus: string; - protocolType: string; - priceNo: string | null; - priceName: string | null; - dmaPartition: string | null; - supplyTypes: string; - deviceId: string; - deviceName: string; - deviceType: string; - usageAnalysisType: string; - installedTime: string; +/** + * Download meter template Excel file + */ +export async function downloadMeterTemplate(): Promise { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + }, + }); + + if (!response.ok) { + throw new Error('Error descargando la plantilla'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'plantilla_medidores.xlsx'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); } - -export const fetchMeters = async (): Promise => { - const pageSize = 9999; - try { - const url = new URL(METERS_API_URL); - url.searchParams.set('viewId', 'vwo7tqwu8fi6ie83'); - url.searchParams.set('pageSize', pageSize.toString()); - - const response = await fetch(url.toString(), { - method: "GET", - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error("Failed to fetch meters"); - } - - const data: MetersResponse = await response.json(); - const ans = data.records.map((r: MeterRecord) => ({ - id: r.id, - createdAt: r.fields.CreatedAt || "", - updatedAt: r.fields.UpdatedAt || "", - areaName: r.fields["Area Name"] || "", - accountNumber: r.fields["Account Number"] || null, - userName: r.fields["User Name"] || null, - userAddress: r.fields["User Address"] || null, - meterSerialNumber: r.fields["Meter S/N"] || "", - meterName: r.fields["Meter Name"] || "", - meterStatus: r.fields["Meter Status"] || "", - protocolType: r.fields["Protocol Type"] || "", - priceNo: r.fields["Price No."] || null, - priceName: r.fields["Price Name"] || null, - dmaPartition: r.fields["DMA Partition"] || null, - supplyTypes: r.fields["Supply Types"] || "", - deviceId: r.fields["Device ID"] || "", - deviceName: r.fields["Device Name"] || "", - deviceType: r.fields["Device Type"] || "", - usageAnalysisType: r.fields["Usage Analysis Type"] || "", - installedTime: r.fields["installed Time"] || "", - })); - - return ans; - } catch (error) { - console.error("Error fetching meters:", error); - throw error; - } -}; - -export const createMeter = async ( - meterData: Omit -): Promise => { - try { - const response = await fetch(METERS_API_URL, { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - fields: { - CreatedAt: meterData.createdAt, - UpdatedAt: meterData.updatedAt, - "Area Name": meterData.areaName, - "Account Number": meterData.accountNumber, - "User Name": meterData.userName, - "User Address": meterData.userAddress, - "Meter S/N": meterData.meterSerialNumber, - "Meter Name": meterData.meterName, - "Meter Status": meterData.meterStatus, - "Protocol Type": meterData.protocolType, - "Price No.": meterData.priceNo, - "Price Name": meterData.priceName, - "DMA Partition": meterData.dmaPartition, - "Supply Types": meterData.supplyTypes, - "Device ID": meterData.deviceId, - "Device Name": meterData.deviceName, - "Device Type": meterData.deviceType, - "Usage Analysis Type": meterData.usageAnalysisType, - "Installed Time": meterData.installedTime, - }, - }), - }); - - if (!response.ok) { - throw new Error( - `Failed to create meter: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - const createdRecord = data.records?.[0]; - - if (!createdRecord) { - throw new Error("Invalid response format: no record returned"); - } - - return { - id: createdRecord.id, - createdAt: createdRecord.fields.CreatedAt || meterData.createdAt, - updatedAt: createdRecord.fields.UpdatedAt || meterData.updatedAt, - areaName: createdRecord.fields["Area Name"] || meterData.areaName, - accountNumber: - createdRecord.fields["Account Number"] || meterData.accountNumber, - userName: createdRecord.fields["User Name"] || meterData.userName, - userAddress: - createdRecord.fields["User Address"] || meterData.userAddress, - meterSerialNumber: - createdRecord.fields["Meter S/N"] || meterData.meterSerialNumber, - meterName: createdRecord.fields["Meter Name"] || meterData.meterName, - meterStatus: - createdRecord.fields["Meter Status"] || meterData.meterStatus, - protocolType: - createdRecord.fields["Protocol Type"] || meterData.protocolType, - priceNo: createdRecord.fields["Price No."] || meterData.priceNo, - priceName: createdRecord.fields["Price Name"] || meterData.priceName, - dmaPartition: - createdRecord.fields["DMA Partition"] || meterData.dmaPartition, - supplyTypes: - createdRecord.fields["Supply Types"] || meterData.supplyTypes, - deviceId: createdRecord.fields["Device ID"] || meterData.deviceId, - deviceName: createdRecord.fields["Device Name"] || meterData.deviceName, - deviceType: createdRecord.fields["Device Type"] || meterData.deviceType, - usageAnalysisType: - createdRecord.fields["Usage Analysis Type"] || - meterData.usageAnalysisType, - installedTime: - createdRecord.fields["Installed Time"] || meterData.installedTime, - }; - } catch (error) { - console.error("Error creating meter:", error); - throw error; - } -}; - -export const updateMeter = async ( - id: string, - meterData: Omit -): Promise => { - try { - const response = await fetch(METERS_API_URL, { - method: "PATCH", - headers: getAuthHeaders(), - body: JSON.stringify({ - id: id, - fields: { - CreatedAt: meterData.createdAt, - UpdatedAt: meterData.updatedAt, - "Area Name": meterData.areaName, - "Account Number": meterData.accountNumber, - "User Name": meterData.userName, - "User Address": meterData.userAddress, - "Meter S/N": meterData.meterSerialNumber, - "Meter Name": meterData.meterName, - "Meter Status": meterData.meterStatus, - "Protocol Type": meterData.protocolType, - "Price No.": meterData.priceNo, - "Price Name": meterData.priceName, - "DMA Partition": meterData.dmaPartition, - "Supply Types": meterData.supplyTypes, - "Device ID": meterData.deviceId, - "Device Name": meterData.deviceName, - "Device Type": meterData.deviceType, - "Usage Analysis Type": meterData.usageAnalysisType, - "Installed Time": meterData.installedTime, - }, - }), - }); - - if (!response.ok) { - if (response.status === 400) { - const errorData = await response.json(); - throw new Error( - `Bad Request: ${errorData.msg || "Invalid data provided"}` - ); - } - throw new Error( - `Failed to update meter: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - const updatedRecord = data.records?.[0]; - - if (!updatedRecord) { - throw new Error("Invalid response format: no record returned"); - } - - return { - id: updatedRecord.id, - createdAt: updatedRecord.fields.CreatedAt || meterData.createdAt, - updatedAt: updatedRecord.fields.UpdatedAt || meterData.updatedAt, - areaName: updatedRecord.fields["Area Name"] || meterData.areaName, - accountNumber: - updatedRecord.fields["Account Number"] || meterData.accountNumber, - userName: updatedRecord.fields["User Name"] || meterData.userName, - userAddress: - updatedRecord.fields["User Address"] || meterData.userAddress, - meterSerialNumber: - updatedRecord.fields["Meter S/N"] || meterData.meterSerialNumber, - meterName: updatedRecord.fields["Meter Name"] || meterData.meterName, - meterStatus: - updatedRecord.fields["Meter Status"] || meterData.meterStatus, - protocolType: - updatedRecord.fields["Protocol Type"] || meterData.protocolType, - priceNo: updatedRecord.fields["Price No."] || meterData.priceNo, - priceName: updatedRecord.fields["Price Name"] || meterData.priceName, - dmaPartition: - updatedRecord.fields["DMA Partition"] || meterData.dmaPartition, - supplyTypes: - updatedRecord.fields["Supply Types"] || meterData.supplyTypes, - deviceId: updatedRecord.fields["Device ID"] || meterData.deviceId, - deviceName: updatedRecord.fields["Device Name"] || meterData.deviceName, - deviceType: updatedRecord.fields["Device Type"] || meterData.deviceType, - usageAnalysisType: - updatedRecord.fields["Usage Analysis Type"] || - meterData.usageAnalysisType, - installedTime: - updatedRecord.fields["Installed Time"] || meterData.installedTime, - }; - } catch (error) { - console.error("Error updating meter:", error); - throw error; - } -}; - -export const deleteMeter = async (id: string): Promise => { - try { - const response = await fetch(METERS_API_URL, { - method: "DELETE", - headers: getAuthHeaders(), - body: JSON.stringify({ - id: id, - }), - }); - - if (!response.ok) { - if (response.status === 400) { - const errorData = await response.json(); - throw new Error( - `Bad Request: ${errorData.msg || "Invalid data provided"}` - ); - } - throw new Error( - `Failed to delete meter: ${response.status} ${response.statusText}` - ); - } - } catch (error) { - console.error("Error deleting meter:", error); - throw error; - } -}; diff --git a/src/api/projects.ts b/src/api/projects.ts index 37cda9d..ae838ac 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -1,247 +1,130 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; -export const PROJECTS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/m9882vn3xb31e29/records`; -const API_TOKEN = import.meta.env.VITE_API_TOKEN; +/** + * Projects API + * Handles all project-related API operations using the backend API client + */ -export const getAuthHeaders = () => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${API_TOKEN}`, -}); +import { apiClient } from './client'; -export interface ProjectRecord { - id: number; - fields: { - "Area Name"?: string; - "Device S/N"?: string; - "Device Name"?: string; - "Device Type"?: string; - "Device Status"?: string; - Operator?: string; - "Installed Time"?: string; - "Communication time"?: string; - "Instruction Manual"?: string | null; - }; +// Helper to convert snake_case to camelCase +function snakeToCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } -export interface ProjectsResponse { - records: ProjectRecord[]; - next?: string; - prev?: string; - nestedNext?: string; - nestedPrev?: string; +// Transform object keys from snake_case to camelCase +function transformKeys(obj: Record): T { + const transformed: Record = {}; + for (const key in obj) { + const camelKey = snakeToCamel(key); + const value = obj[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + transformed[camelKey] = transformKeys(value as Record); + } else { + transformed[camelKey] = value; + } + } + return transformed as T; } +// Transform array of objects +function transformArray(arr: Record[]): T[] { + return arr.map(item => transformKeys(item)); +} + +/** + * Project entity from the backend + */ export interface Project { id: string; + name: string; + description: string | null; areaName: string; - deviceSN: string; - deviceName: string; - deviceType: string; - deviceStatus: "ACTIVE" | "INACTIVE"; - operator: string; - installedTime: string; - communicationTime: string; + location: string | null; + status: string; + createdBy: string; + createdAt: string; + updatedAt: string; } -export const fetchProjectNames = async (): Promise => { - try { - const response = await fetch(PROJECTS_API_URL, { - method: "GET", - headers: getAuthHeaders(), - }); +/** + * Input data for creating or updating a project + */ +export interface ProjectInput { + name: string; + description?: string; + areaName: string; + location?: string; + status?: string; +} - if (!response.ok) { - throw new Error("Failed to fetch projects"); - } +/** + * Fetch all projects + * @returns Promise resolving to an array of projects + */ +export async function fetchProjects(): Promise { + const response = await apiClient.get[]>('/api/projects'); + return transformArray(response); +} - const data: ProjectsResponse = await response.json(); +/** + * Fetch a single project by ID + * @param id - The project ID + * @returns Promise resolving to the project + */ +export async function fetchProject(id: string): Promise { + const response = await apiClient.get>(`/api/projects/${id}`); + return transformKeys(response); +} - if (!data.records || data.records.length === 0) { - console.warn("No project records found from API"); - return []; - } - - const projectNames = [ - ...new Set( - data.records - .map((record) => record.fields["Area Name"] || "") - .filter((name) => name) - ), - ]; - - return projectNames; - } catch (error) { - console.error("Error fetching project names:", error); - return []; - } -}; - -export const fetchProjects = async (): Promise => { - try { - const url = new URL(PROJECTS_API_URL); - url.searchParams.set('viewId', 'vwrrxvlzlxi7jfe7'); - - const response = await fetch(url.toString(), { - method: "GET", - headers: getAuthHeaders(), - }); - - if (!response.ok) { - throw new Error("Failed to fetch projects"); - } - - const data: ProjectsResponse = await response.json(); - - return data.records.map((r: ProjectRecord) => ({ - id: r.id.toString(), - areaName: r.fields["Area Name"] ?? "", - deviceSN: r.fields["Device S/N"] ?? "", - deviceName: r.fields["Device Name"] ?? "", - deviceType: r.fields["Device Type"] ?? "", - deviceStatus: - r.fields["Device Status"] === "Installed" ? "ACTIVE" : "INACTIVE", - operator: r.fields["Operator"] ?? "", - installedTime: r.fields["Installed Time"] ?? "", - communicationTime: r.fields["Communication time"] ?? "", - instructionManual: "", - })); - } catch (error) { - console.error("Error fetching projects:", error); - throw error; - } -}; - -export const createProject = async ( - projectData: Omit -): Promise => { - const response = await fetch(PROJECTS_API_URL, { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - fields: { - "Area Name": projectData.areaName, - "Device S/N": projectData.deviceSN, - "Device Name": projectData.deviceName, - "Device Type": projectData.deviceType, - "Device Status": - projectData.deviceStatus === "ACTIVE" ? "Installed" : "Inactive", - Operator: projectData.operator, - "Installed Time": projectData.installedTime, - "Communication time": projectData.communicationTime, - }, - }), - }); - - if (!response.ok) { - throw new Error( - `Failed to create project: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - - const createdRecord = data.records?.[0]; - if (!createdRecord) { - throw new Error("Invalid response format: no record returned"); - } - - return { - id: createdRecord.id.toString(), - areaName: createdRecord.fields["Area Name"] ?? projectData.areaName, - deviceSN: createdRecord.fields["Device S/N"] ?? projectData.deviceSN, - deviceName: createdRecord.fields["Device Name"] ?? projectData.deviceName, - deviceType: createdRecord.fields["Device Type"] ?? projectData.deviceType, - deviceStatus: - createdRecord.fields["Device Status"] === "Installed" - ? "ACTIVE" - : "INACTIVE", - operator: createdRecord.fields["Operator"] ?? projectData.operator, - installedTime: - createdRecord.fields["Installed Time"] ?? projectData.installedTime, - communicationTime: - createdRecord.fields["Communication time"] ?? - projectData.communicationTime, +/** + * Create a new project + * @param data - The project data + * @returns Promise resolving to the created project + */ +export async function createProject(data: ProjectInput): Promise { + const backendData = { + name: data.name, + description: data.description, + area_name: data.areaName, + location: data.location, + status: data.status, }; -}; + const response = await apiClient.post>('/api/projects', backendData); + return transformKeys(response); +} -export const updateProject = async ( - id: string, - projectData: Omit -): Promise => { - const response = await fetch(PROJECTS_API_URL, { - method: "PATCH", - headers: getAuthHeaders(), - body: JSON.stringify({ - id: parseInt(id), - fields: { - "Area Name": projectData.areaName, - "Device S/N": projectData.deviceSN, - "Device Name": projectData.deviceName, - "Device Type": projectData.deviceType, - "Device Status": - projectData.deviceStatus === "ACTIVE" ? "Installed" : "Inactive", - Operator: projectData.operator, - "Installed Time": projectData.installedTime, - "Communication time": projectData.communicationTime, - }, - }), - }); +/** + * Update an existing project + * @param id - The project ID + * @param data - The updated project data + * @returns Promise resolving to the updated project + */ +export async function updateProject(id: string, data: Partial): Promise { + const backendData: Record = {}; + if (data.name !== undefined) backendData.name = data.name; + if (data.description !== undefined) backendData.description = data.description; + if (data.areaName !== undefined) backendData.area_name = data.areaName; + if (data.location !== undefined) backendData.location = data.location; + if (data.status !== undefined) backendData.status = data.status; - if (!response.ok) { - if (response.status === 400) { - const errorData = await response.json(); - throw new Error( - `Bad Request: ${errorData.msg || "Invalid data provided"}` - ); - } - throw new Error( - `Failed to update project: ${response.status} ${response.statusText}` - ); - } + const response = await apiClient.patch>(`/api/projects/${id}`, backendData); + return transformKeys(response); +} - const data = await response.json(); +/** + * Delete a project + * @param id - The project ID + * @returns Promise resolving when the project is deleted + */ +export async function deleteProject(id: string): Promise { + return apiClient.delete(`/api/projects/${id}`); +} - const updatedRecord = data.records?.[0]; - if (!updatedRecord) { - throw new Error("Invalid response format: no record returned"); - } - - return { - id: updatedRecord.id.toString(), - areaName: updatedRecord.fields["Area Name"] ?? projectData.areaName, - deviceSN: updatedRecord.fields["Device S/N"] ?? projectData.deviceSN, - deviceName: updatedRecord.fields["Device Name"] ?? projectData.deviceName, - deviceType: updatedRecord.fields["Device Type"] ?? projectData.deviceType, - deviceStatus: - updatedRecord.fields["Device Status"] === "Installed" - ? "ACTIVE" - : "INACTIVE", - operator: updatedRecord.fields["Operator"] ?? projectData.operator, - installedTime: - updatedRecord.fields["Installed Time"] ?? projectData.installedTime, - communicationTime: - updatedRecord.fields["Communication time"] ?? - projectData.communicationTime, - }; -}; - -export const deleteProject = async (id: string): Promise => { - const response = await fetch(PROJECTS_API_URL, { - method: "DELETE", - headers: getAuthHeaders(), - body: JSON.stringify({ - id: id, - }), - }); - - if (!response.ok) { - if (response.status === 400) { - const errorData = await response.json(); - throw new Error( - `Bad Request: ${errorData.msg || "Invalid data provided"}` - ); - } - throw new Error( - `Failed to delete project: ${response.status} ${response.statusText}` - ); - } -}; +/** + * Fetch unique area names from all projects + * @returns Promise resolving to an array of unique area names + */ +export async function fetchProjectNames(): Promise { + const projects = await fetchProjects(); + const areaNames = [...new Set(projects.map(p => p.areaName).filter(Boolean))]; + return areaNames; +} diff --git a/src/api/readings.ts b/src/api/readings.ts new file mode 100644 index 0000000..93710ea --- /dev/null +++ b/src/api/readings.ts @@ -0,0 +1,186 @@ +/** + * Readings API + * Handles all meter reading-related API operations using the backend API client + */ + +import { apiClient } from './client'; + +// Helper to convert snake_case to camelCase +function snakeToCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +// Transform object keys from snake_case to camelCase +function transformKeys(obj: Record): T { + const transformed: Record = {}; + for (const key in obj) { + const camelKey = snakeToCamel(key); + const value = obj[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + transformed[camelKey] = transformKeys(value as Record); + } else { + transformed[camelKey] = value; + } + } + return transformed as T; +} + +// Transform array of objects +function transformArray(arr: Record[]): T[] { + return arr.map(item => transformKeys(item)); +} + +/** + * Meter reading entity from the backend + */ +export interface MeterReading { + id: string; + meterId: string; + deviceId: string | null; + readingValue: number; + readingType: string; + batteryLevel: number | null; + signalStrength: number | null; + rawPayload: string | null; + receivedAt: string; + createdAt: string; + // From join with meters + meterSerialNumber: string; + meterName: string; + areaName: string | null; + projectId: string; +} + +/** + * Consumption summary statistics + */ +export interface ConsumptionSummary { + totalReadings: number; + totalMeters: number; + avgReading: number; + lastReadingDate: string | null; +} + +/** + * Pagination info from API response + */ +export interface Pagination { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +/** + * Paginated response + */ +export interface PaginatedResponse { + data: T[]; + pagination: Pagination; +} + +/** + * Filters for fetching readings + */ +export interface ReadingFilters { + meterId?: string; + projectId?: string; + areaName?: string; + startDate?: string; + endDate?: string; + readingType?: string; + page?: number; + pageSize?: number; +} + +/** + * Fetch all readings with optional filtering and pagination + * @param filters - Optional filters for the query + * @returns Promise resolving to paginated readings + */ +export async function fetchReadings(filters?: ReadingFilters): Promise> { + const params: Record = {}; + + if (filters?.meterId) params.meter_id = filters.meterId; + if (filters?.projectId) params.project_id = filters.projectId; + if (filters?.areaName) params.area_name = filters.areaName; + if (filters?.startDate) params.start_date = filters.startDate; + if (filters?.endDate) params.end_date = filters.endDate; + if (filters?.readingType) params.reading_type = filters.readingType; + if (filters?.page) params.page = filters.page; + if (filters?.pageSize) params.pageSize = filters.pageSize; + + const response = await apiClient.get<{ + data: Record[]; + pagination: Pagination; + }>('/api/readings', { params }); + + return { + data: transformArray(response.data), + pagination: response.pagination, + }; +} + +/** + * Fetch a single reading by ID + * @param id - The reading ID + * @returns Promise resolving to the reading + */ +export async function fetchReading(id: string): Promise { + const response = await apiClient.get>(`/api/readings/${id}`); + return transformKeys(response); +} + +/** + * Fetch consumption summary statistics + * @param projectId - Optional project ID to filter + * @returns Promise resolving to the summary + */ +export async function fetchConsumptionSummary(projectId?: string): Promise { + const params = projectId ? { project_id: projectId } : undefined; + const response = await apiClient.get>('/api/readings/summary', { params }); + return transformKeys(response); +} + +/** + * Input data for creating a reading + */ +export interface ReadingInput { + meterId: string; + deviceId?: string; + readingValue: number; + readingType?: string; + batteryLevel?: number; + signalStrength?: number; + rawPayload?: string; + receivedAt?: string; +} + +/** + * Create a new reading + * @param data - The reading data + * @returns Promise resolving to the created reading + */ +export async function createReading(data: ReadingInput): Promise { + const backendData = { + meter_id: data.meterId, + device_id: data.deviceId, + reading_value: data.readingValue, + reading_type: data.readingType, + battery_level: data.batteryLevel, + signal_strength: data.signalStrength, + raw_payload: data.rawPayload, + received_at: data.receivedAt, + }; + const response = await apiClient.post>('/api/readings', backendData); + return transformKeys(response); +} + +/** + * Delete a reading + * @param id - The reading ID + * @returns Promise resolving when the reading is deleted + */ +export async function deleteReading(id: string): Promise { + return apiClient.delete(`/api/readings/${id}`); +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..075deb3 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,133 @@ +/** + * API Types and Error Classes + * Common types used across the API client + */ + +/** + * Standard API response wrapper for successful responses + */ +export interface ApiSuccessResponse { + success: true; + data: T; +} + +/** + * Standard API response wrapper for error responses + */ +export interface ApiErrorResponse { + success: false; + error: { + message: string; + errors?: string[]; + }; +} + +/** + * Union type for all API responses + */ +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +/** + * Pagination metadata + */ +export interface PaginationMeta { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +/** + * Paginated response wrapper + */ +export interface PaginatedResponse { + success: true; + data: T[]; + pagination: PaginationMeta; +} + +/** + * Custom API Error class with status code and validation errors + */ +export class ApiError extends Error { + public readonly status: number; + public readonly errors?: string[]; + + constructor(message: string, status: number, errors?: string[]) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.errors = errors; + + // Ensure instanceof works correctly + Object.setPrototypeOf(this, ApiError.prototype); + } + + /** + * Check if this error is an authentication error + */ + isAuthError(): boolean { + return this.status === 401; + } + + /** + * Check if this error is a forbidden error + */ + isForbiddenError(): boolean { + return this.status === 403; + } + + /** + * Check if this error is a not found error + */ + isNotFoundError(): boolean { + return this.status === 404; + } + + /** + * Check if this error is a validation error + */ + isValidationError(): boolean { + return this.status === 400 || this.status === 422; + } + + /** + * Check if this error is a server error + */ + isServerError(): boolean { + return this.status >= 500; + } + + /** + * Convert error to a plain object for logging or serialization + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + status: this.status, + errors: this.errors, + }; + } +} + +/** + * Type guard to check if a response is successful + */ +export function isApiSuccess(response: ApiResponse): response is ApiSuccessResponse { + return response.success === true; +} + +/** + * Type guard to check if a response is an error + */ +export function isApiError(response: ApiResponse): response is ApiErrorResponse { + return response.success === false; +} + +/** + * Type guard to check if an error is an ApiError instance + */ +export function isApiErrorInstance(error: unknown): error is ApiError { + return error instanceof ApiError; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5e61852..458d77f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Home, Settings, - WaterDrop, ExpandMore, ExpandLess, Menu, @@ -106,6 +105,15 @@ export default function Sidebar({ setPage }: SidebarProps) { Meters + +
  • + +
  • )} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index dec6b32..a6d4fe3 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -117,7 +117,7 @@ export default function Home({ ); const filteredProjects = useMemo( - () => [...new Set(filteredMeters.map((m) => m.areaName))], + () => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[], [filteredMeters] ); @@ -125,7 +125,7 @@ export default function Home({ () => filteredProjects.map((projectName) => ({ name: projectName, - meterCount: filteredMeters.filter((m) => m.areaName === projectName) + meterCount: filteredMeters.filter((m) => m.projectName === projectName) .length, })), [filteredProjects, filteredMeters] diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 3b62e6b..f720ffc 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,27 +1,26 @@ import { useMemo, useState } from "react"; -import { Lock, User, Eye, EyeOff, Loader2, Check } from "lucide-react"; +import { Lock, Mail, Eye, EyeOff, Loader2 } from "lucide-react"; import grhWatermark from "../assets/images/grhWatermark.png"; +import { login } from "../api/auth"; -type Form = { usuario: string; contrasena: string }; +type Form = { email: string; password: string }; type LoginPageProps = { - onSuccess: (payload?: { token?: string }) => void; + onSuccess: () => void; }; export default function LoginPage({ onSuccess }: LoginPageProps) { - const [form, setForm] = useState
    ({ usuario: "", contrasena: "" }); + const [form, setForm] = useState({ email: "", password: "" }); const [showPass, setShowPass] = useState(false); const [loading, setLoading] = useState(false); const [serverError, setServerError] = useState(""); - const [notRobot, setNotRobot] = useState(false); const errors = useMemo(() => { - const e: Partial> = {}; - if (!form.usuario.trim()) e.usuario = "El usuario es obligatorio."; - if (!form.contrasena) e.contrasena = "La contraseña es obligatoria."; - if (!notRobot) e.robot = "Confirma que no eres un robot."; + const e: Partial> = {}; + if (!form.email.trim()) e.email = "El correo es obligatorio."; + if (!form.password) e.password = "La contraseña es obligatoria."; return e; - }, [form.usuario, form.contrasena, notRobot]); + }, [form.email, form.password]); const canSubmit = Object.keys(errors).length === 0 && !loading; @@ -30,12 +29,13 @@ export default function LoginPage({ onSuccess }: LoginPageProps) { setServerError(""); if (!canSubmit) return; + setLoading(true); try { - setLoading(true); - await new Promise((r) => setTimeout(r, 700)); - onSuccess({ token: "demo" }); - } catch { - setServerError("No se pudo iniciar sesión. Verifica tus datos."); + await login({ email: form.email, password: form.password }); + // Tokens are stored by the auth module + onSuccess(); + } catch (err) { + setServerError(err instanceof Error ? err.message : "Error de autenticación"); } finally { setLoading(false); } @@ -111,27 +111,28 @@ export default function LoginPage({ onSuccess }: LoginPageProps) { )} - {/* Usuario */} + {/* Email */}
    - setForm((s) => ({ ...s, usuario: e.target.value })) + setForm((s) => ({ ...s, email: e.target.value })) } className="w-full border-b border-slate-300 py-2 pr-10 outline-none focus:border-slate-600" /> -
    - {errors.usuario && ( + {errors.email && (

    - {errors.usuario} + {errors.email}

    )}
    @@ -143,9 +144,9 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
    - setForm((s) => ({ ...s, contrasena: e.target.value })) + setForm((s) => ({ ...s, password: e.target.value })) } type={showPass ? "text" : "password"} className="w-full border-b border-slate-300 py-2 pr-16 outline-none focus:border-slate-600" @@ -162,36 +163,13 @@ export default function LoginPage({ onSuccess }: LoginPageProps) { size={18} />
    - {errors.contrasena && ( + {errors.password && (

    - {errors.contrasena} + {errors.password}

    )} - {/* NO SOY UN ROBOT */} -
    - - No soy un robot - - reCAPTCHA - -
    - - {errors.robot && ( -

    {errors.robot}

    - )} - {/* Botón */} diff --git a/src/pages/concentrators/ConcentratorsPage.tsx b/src/pages/concentrators/ConcentratorsPage.tsx index 009d464..eb909f3 100644 --- a/src/pages/concentrators/ConcentratorsPage.tsx +++ b/src/pages/concentrators/ConcentratorsPage.tsx @@ -1,23 +1,23 @@ -// src/pages/concentrators/ConcentratorsPage.tsx import { useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import ConfirmModal from "../../components/layout/common/ConfirmModal"; - -import { createConcentrator, deleteConcentrator, updateConcentrator, type Concentrator } from "../../api/concentrators"; - -// ✅ hook es named export y pide currentUser +import { + createConcentrator, + deleteConcentrator, + updateConcentrator, + type Concentrator, + type ConcentratorInput, +} from "../../api/concentrators"; import { useConcentrators } from "./useConcentrators"; - -// ✅ UI pieces import ConcentratorsSidebar from "./ConcentratorsSidebar"; import ConcentratorsTable from "./ConcentratorsTable"; import ConcentratorsModal from "./ConcentratorsModal"; - export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES"; export type ProjectStatus = "ACTIVO" | "INACTIVO"; export type ProjectCard = { + id: string; name: string; region: string; projects: number; @@ -33,91 +33,53 @@ type User = { project?: string; }; -export type GatewayData = { - "Gateway ID": number; - "Gateway EUI": string; - "Gateway Name": string; - "Gateway Description": string; - "Antenna Placement": "Indoor" | "Outdoor"; - concentratorId?: string; -}; - export default function ConcentratorsPage() { - // ✅ Simulación de usuario actual const currentUser: User = { role: "SUPER_ADMIN", project: "CESPT", }; - // ✅ Hook (solo cubre: projects + fetch + sampleView + selectedProject + loading + projectsData) const c = useConcentrators(currentUser); - const [typesMenuOpen, setTypesMenuOpen] = useState(false); - const [search, setSearch] = useState(""); const [activeConcentrator, setActiveConcentrator] = useState(null); - const [confirmOpen, setConfirmOpen] = useState(false); const [deleting, setDeleting] = useState(false); - const [showModal, setShowModal] = useState(false); - const [editingSerial, setEditingSerial] = useState(null); + const [editingId, setEditingId] = useState(null); - const getEmptyConcentrator = (): Omit => ({ - "Area Name": c.selectedProject, - "Device S/N": "", - "Device Name": "", - "Device Time": new Date().toISOString(), - "Device Status": "ACTIVE", - Operator: "", - "Installed Time": new Date().toISOString().slice(0, 10), - "Communication Time": new Date().toISOString(), - "Instruction Manual": "", + const getEmptyForm = (): ConcentratorInput => ({ + serialNumber: "", + name: "", + projectId: "", + location: "", + type: "LORA", + status: "ACTIVE", + ipAddress: "", + firmwareVersion: "", }); - const getEmptyGatewayData = (): GatewayData => ({ - "Gateway ID": 0, - "Gateway EUI": "", - "Gateway Name": "", - "Gateway Description": "", - "Antenna Placement": "Indoor", - }); + const [form, setForm] = useState(getEmptyForm()); + const [errors, setErrors] = useState>({}); - const [form, setForm] = useState>(getEmptyConcentrator()); - const [gatewayForm, setGatewayForm] = useState(getEmptyGatewayData()); - const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); - - // ✅ Tabla filtrada por search (usa lo que YA filtró el hook por proyecto) const searchFiltered = useMemo(() => { if (!c.isGeneral) return []; return c.filteredConcentrators.filter((row) => { const q = search.trim().toLowerCase(); if (!q) return true; - const name = (row["Device Name"] ?? "").toLowerCase(); - const sn = (row["Device S/N"] ?? "").toLowerCase(); + const name = (row.name ?? "").toLowerCase(); + const sn = (row.serialNumber ?? "").toLowerCase(); return name.includes(q) || sn.includes(q); }); }, [c.filteredConcentrators, c.isGeneral, search]); - // ========================= - // CRUD (solo GENERAL) - // ========================= const validateForm = () => { - const next: { [key: string]: boolean } = {}; + const next: Record = {}; - if (!form["Device Name"].trim()) next["Device Name"] = true; - if (!form["Device S/N"].trim()) next["Device S/N"] = true; - if (!form["Operator"].trim()) next["Operator"] = true; - if (!form["Instruction Manual"].trim()) next["Instruction Manual"] = true; - if (!form["Installed Time"]) next["Installed Time"] = true; - if (!form["Device Time"]) next["Device Time"] = true; - if (!form["Communication Time"]) next["Communication Time"] = true; - - if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) next["Gateway ID"] = true; - if (!gatewayForm["Gateway EUI"].trim()) next["Gateway EUI"] = true; - if (!gatewayForm["Gateway Name"].trim()) next["Gateway Name"] = true; - if (!gatewayForm["Gateway Description"].trim()) next["Gateway Description"] = true; + if (!form.name.trim()) next["name"] = true; + if (!form.serialNumber.trim()) next["serialNumber"] = true; + if (!form.projectId.trim()) next["projectId"] = true; setErrors(next); return Object.keys(next).length === 0; @@ -128,23 +90,17 @@ export default function ConcentratorsPage() { if (!validateForm()) return; try { - if (editingSerial) { - const toUpdate = c.concentrators.find((x) => x["Device S/N"] === editingSerial); - if (!toUpdate) throw new Error("Concentrator not found"); - - const updated = await updateConcentrator(toUpdate.id, form); - - // actualiza en memoria (el hook expone setConcentrators) - c.setConcentrators((prev) => prev.map((x) => (x.id === toUpdate.id ? updated : x))); + if (editingId) { + const updated = await updateConcentrator(editingId, form); + c.setConcentrators((prev) => prev.map((x) => (x.id === editingId ? updated : x))); } else { const created = await createConcentrator(form); c.setConcentrators((prev) => [...prev, created]); } setShowModal(false); - setEditingSerial(null); - setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject }); - setGatewayForm(getEmptyGatewayData()); + setEditingId(null); + setForm(getEmptyForm()); setErrors({}); setActiveConcentrator(null); } catch (err) { @@ -167,28 +123,32 @@ export default function ConcentratorsPage() { } }; - // ========================= - // Date helpers para modal - // ========================= - function toDatetimeLocalValue(value?: string) { - if (!value) return ""; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return ""; - const pad = (n: number) => String(n).padStart(2, "0"); - const yyyy = d.getFullYear(); - const mm = pad(d.getMonth() + 1); - const dd = pad(d.getDate()); - const hh = pad(d.getHours()); - const mi = pad(d.getMinutes()); - return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; - } + const openEditModal = () => { + if (!c.isGeneral || !activeConcentrator) return; - function fromDatetimeLocalValue(value: string) { - if (!value) return ""; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return ""; - return d.toISOString(); - } + setEditingId(activeConcentrator.id); + setForm({ + serialNumber: activeConcentrator.serialNumber, + name: activeConcentrator.name, + projectId: activeConcentrator.projectId, + location: activeConcentrator.location ?? "", + type: activeConcentrator.type ?? "LORA", + status: activeConcentrator.status, + ipAddress: activeConcentrator.ipAddress ?? "", + firmwareVersion: activeConcentrator.firmwareVersion ?? "", + }); + setErrors({}); + setShowModal(true); + }; + + const openCreateModal = () => { + if (!c.isGeneral) return; + + setForm(getEmptyForm()); + setErrors({}); + setEditingId(null); + setShowModal(true); + }; return (
    @@ -202,8 +162,6 @@ export default function ConcentratorsPage() { onChangeSampleView={(next: SampleView) => { c.setSampleView(next); setTypesMenuOpen(false); - - // resets UI c.setSelectedProject(""); setActiveConcentrator(null); setSearch(""); @@ -238,46 +196,15 @@ export default function ConcentratorsPage() {
    @@ -332,8 +259,8 @@ export default function ConcentratorsPage() { open={confirmOpen} title="Eliminar concentrador" message={`¿Estás seguro que quieres eliminar "${ - activeConcentrator?.["Device Name"] ?? "este concentrador" - }"? Esta acción no se puede deshacer.`} + activeConcentrator?.name ?? "este concentrador" + }" (${activeConcentrator?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`} confirmText="Eliminar" cancelText="Cancelar" danger @@ -354,18 +281,14 @@ export default function ConcentratorsPage() { {showModal && c.isGeneral && ( { setShowModal(false); - setGatewayForm(getEmptyGatewayData()); setErrors({}); }} onSave={handleSave} diff --git a/src/pages/concentrators/ConcentratorsSidebar.tsx b/src/pages/concentrators/ConcentratorsSidebar.tsx index db64c58..0afff09 100644 --- a/src/pages/concentrators/ConcentratorsSidebar.tsx +++ b/src/pages/concentrators/ConcentratorsSidebar.tsx @@ -59,7 +59,9 @@ export default function ConcentratorsSidebar({ Tipo: {sampleViewLabel} {" • "} Seleccionado:{" "} - {selectedProject || "—"} + + {projects.find((p) => p.id === selectedProject)?.name || "—"} +

    @@ -132,12 +134,12 @@ export default function ConcentratorsSidebar({ ) : ( projects.map((p) => { - const active = p.name === selectedProject; + const active = p.id === selectedProject; return (
    onSelectProject(p.name)} + key={p.id} + onClick={() => onSelectProject(p.id)} className={[ "rounded-xl border p-4 transition cursor-pointer", active @@ -211,7 +213,7 @@ export default function ConcentratorsSidebar({ ].join(" ")} onClick={(e) => { e.stopPropagation(); - onSelectProject(p.name); + onSelectProject(p.id); }} > {active ? "Seleccionado" : "Seleccionar"} diff --git a/src/pages/concentrators/ConcentratorsTable.tsx b/src/pages/concentrators/ConcentratorsTable.tsx index b753913..aeefff2 100644 --- a/src/pages/concentrators/ConcentratorsTable.tsx +++ b/src/pages/concentrators/ConcentratorsTable.tsx @@ -23,45 +23,69 @@ export default function ConcentratorsTable({ isLoading={isLoading} columns={[ { - title: "Device Name", - field: "Device Name", - render: (rowData: any) => rowData["Device Name"] || "-", + title: "Serial", + field: "serialNumber", + render: (rowData: Concentrator) => rowData.serialNumber || "-", }, { - title: "Device S/N", - field: "Device S/N", - render: (rowData: any) => rowData["Device S/N"] || "-", + title: "Nombre", + field: "name", + render: (rowData: Concentrator) => rowData.name || "-", }, { - title: "Device Status", - field: "Device Status", - render: (rowData: any) => ( + title: "Tipo", + field: "type", + render: (rowData: Concentrator) => { + const typeLabels: Record = { + LORA: "LoRa", + LORAWAN: "LoRaWAN", + GRANDES: "Grandes Consumidores", + }; + const typeColors: Record = { + LORA: "text-green-600 border-green-600", + LORAWAN: "text-purple-600 border-purple-600", + GRANDES: "text-orange-600 border-orange-600", + }; + const type = rowData.type || "LORA"; + return ( + + {typeLabels[type] || type} + + ); + }, + }, + { + title: "Estado", + field: "status", + render: (rowData: Concentrator) => ( - {rowData["Device Status"] || "-"} + {rowData.status || "-"} ), }, { - title: "Operator", - field: "Operator", - render: (rowData: any) => rowData["Operator"] || "-", + title: "Ubicación", + field: "location", + render: (rowData: Concentrator) => rowData.location || "-", }, { - title: "Area Name", - field: "Area Name", - render: (rowData: any) => rowData["Area Name"] || "-", + title: "IP", + field: "ipAddress", + render: (rowData: Concentrator) => rowData.ipAddress || "-", }, { - title: "Installed Time", - field: "Installed Time", - type: "date", - render: (rowData: any) => rowData["Installed Time"] || "-", + title: "Última Comunicación", + field: "lastCommunication", + type: "datetime", + render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-", }, ]} data={data} diff --git a/src/pages/concentrators/useConcentrators.ts b/src/pages/concentrators/useConcentrators.ts index 5e13f9a..a24e929 100644 --- a/src/pages/concentrators/useConcentrators.ts +++ b/src/pages/concentrators/useConcentrators.ts @@ -3,6 +3,7 @@ import { fetchConcentrators, type Concentrator, } from "../../api/concentrators"; +import { fetchProjects, type Project } from "../../api/projects"; import type { ProjectCard, SampleView } from "./ConcentratorsPage"; type User = { @@ -16,6 +17,7 @@ export function useConcentrators(currentUser: User) { const [loadingProjects, setLoadingProjects] = useState(true); const [loadingConcentrators, setLoadingConcentrators] = useState(true); + const [projects, setProjects] = useState([]); const [allProjects, setAllProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(""); @@ -51,58 +53,49 @@ export function useConcentrators(currentUser: User) { [allProjects, currentUser.role, currentUser.project] ); - const loadConcentrators = async () => { - if (!isGeneral) return; - - setLoadingConcentrators(true); + const loadProjects = async () => { setLoadingProjects(true); - try { - const raw = await fetchConcentrators(); - - const normalized = raw.map((c: any) => { - const preferredName = - c["Device Alias"] || - c["Device Label"] || - c["Device Display Name"] || - c.deviceName || - c.name || - c["Device Name"] || - ""; - - return { - ...c, - "Device Name": preferredName, - }; - }); - - const projectsArray = [ - ...new Set(normalized.map((r: any) => r["Area Name"])), - ].filter(Boolean) as string[]; - - setAllProjects(projectsArray); - setConcentrators(normalized); + const projectsData = await fetchProjects(); + setProjects(projectsData); + const projectIds = projectsData.map((p) => p.id); + setAllProjects(projectIds); setSelectedProject((prev) => { if (prev) return prev; if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) { return currentUser.project; } - return projectsArray[0] ?? ""; + return projectIds[0] ?? ""; }); } catch (err) { - console.error("Error loading concentrators:", err); + console.error("Error loading projects:", err); + setProjects([]); setAllProjects([]); - setConcentrators([]); - setSelectedProject(""); } finally { - setLoadingConcentrators(false); setLoadingProjects(false); } }; - // init + const loadConcentrators = async () => { + if (!isGeneral) return; + + setLoadingConcentrators(true); + + try { + const data = await fetchConcentrators(); + setConcentrators(data); + } catch (err) { + console.error("Error loading concentrators:", err); + setConcentrators([]); + } finally { + setLoadingConcentrators(false); + } + }; + + // init - load projects and concentrators useEffect(() => { + loadProjects(); loadConcentrators(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -110,6 +103,7 @@ export function useConcentrators(currentUser: User) { // view changes useEffect(() => { if (isGeneral) { + loadProjects(); loadConcentrators(); } else { setLoadingProjects(false); @@ -136,7 +130,7 @@ export function useConcentrators(currentUser: User) { if (selectedProject) { setFilteredConcentrators( - concentrators.filter((c) => c["Area Name"] === selectedProject) + concentrators.filter((c) => c.projectId === selectedProject) ); } else { setFilteredConcentrators(concentrators); @@ -146,8 +140,13 @@ export function useConcentrators(currentUser: User) { // sidebar cards (general) const projectsDataGeneral: ProjectCard[] = useMemo(() => { const counts = concentrators.reduce>((acc, c) => { - const area = c["Area Name"] ?? "SIN PROYECTO"; - acc[area] = (acc[area] ?? 0) + 1; + const project = c.projectId ?? "SIN PROYECTO"; + acc[project] = (acc[project] ?? 0) + 1; + return acc; + }, {}); + + const projectNameMap = projects.reduce>((acc, p) => { + acc[p.id] = p.name; return acc; }, {}); @@ -155,17 +154,18 @@ export function useConcentrators(currentUser: User) { const baseContact = "Operaciones"; const baseLastSync = "Hace 1 h"; - return visibleProjects.map((name) => ({ - name, + return visibleProjects.map((projectId) => ({ + id: projectId, + name: projectNameMap[projectId] ?? projectId, region: baseRegion, projects: 1, - concentrators: counts[name] ?? 0, + concentrators: counts[projectId] ?? 0, activeAlerts: 0, lastSync: baseLastSync, contact: baseContact, - status: "ACTIVO", + status: "ACTIVO" as const, })); - }, [concentrators, visibleProjects]); + }, [concentrators, visibleProjects, projects]); // sidebar cards (mock) const projectsDataMock: Record, ProjectCard[]> = @@ -173,6 +173,7 @@ export function useConcentrators(currentUser: User) { () => ({ LORA: [ { + id: "mock-lora-centro", name: "LoRa - Zona Centro", region: "Baja California", projects: 1, @@ -183,6 +184,7 @@ export function useConcentrators(currentUser: User) { status: "ACTIVO", }, { + id: "mock-lora-este", name: "LoRa - Zona Este", region: "Baja California", projects: 1, @@ -195,6 +197,7 @@ export function useConcentrators(currentUser: User) { ], LORAWAN: [ { + id: "mock-lorawan-industrial", name: "LoRaWAN - Industrial", region: "Baja California", projects: 1, @@ -207,6 +210,7 @@ export function useConcentrators(currentUser: User) { ], GRANDES: [ { + id: "mock-grandes-convenios", name: "Grandes - Convenios", region: "Baja California", projects: 1, diff --git a/src/pages/consumption/ConsumptionPage.tsx b/src/pages/consumption/ConsumptionPage.tsx new file mode 100644 index 0000000..c622859 --- /dev/null +++ b/src/pages/consumption/ConsumptionPage.tsx @@ -0,0 +1,560 @@ +import { useEffect, useState, useMemo } from "react"; +import { + RefreshCcw, + Download, + Search, + Droplets, + TrendingUp, + Zap, + Clock, + ChevronLeft, + ChevronRight, + Filter, + X, + Activity, +} from "lucide-react"; +import { + fetchReadings, + fetchConsumptionSummary, + type MeterReading, + type ConsumptionSummary, + type Pagination, +} from "../../api/readings"; +import { fetchProjects, type Project } from "../../api/projects"; + +export default function ConsumptionPage() { + const [readings, setReadings] = useState([]); + const [summary, setSummary] = useState(null); + const [projects, setProjects] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + pageSize: 100, + total: 0, + totalPages: 0, + }); + + const [loadingReadings, setLoadingReadings] = useState(false); + const [loadingSummary, setLoadingSummary] = useState(false); + + const [selectedProject, setSelectedProject] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [search, setSearch] = useState(""); + const [showFilters, setShowFilters] = useState(false); + + useEffect(() => { + const loadProjects = async () => { + try { + const data = await fetchProjects(); + setProjects(data); + } catch (error) { + console.error("Error loading projects:", error); + } + }; + loadProjects(); + }, []); + + const loadData = async (page = 1) => { + setLoadingReadings(true); + setLoadingSummary(true); + + try { + const [readingsResult, summaryResult] = await Promise.all([ + fetchReadings({ + projectId: selectedProject || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + page, + pageSize: 100, + }), + fetchConsumptionSummary(selectedProject || undefined), + ]); + + setReadings(readingsResult.data); + setPagination(readingsResult.pagination); + setSummary(summaryResult); + } catch (error) { + console.error("Error loading data:", error); + } finally { + setLoadingReadings(false); + setLoadingSummary(false); + } + }; + + useEffect(() => { + loadData(1); + }, [selectedProject, startDate, endDate]); + + const filteredReadings = useMemo(() => { + if (!search.trim()) return readings; + const q = search.toLowerCase(); + return readings.filter( + (r) => + (r.meterSerialNumber ?? "").toLowerCase().includes(q) || + (r.meterName ?? "").toLowerCase().includes(q) || + (r.areaName ?? "").toLowerCase().includes(q) || + String(r.readingValue).includes(q) + ); + }, [readings, search]); + + const formatDate = (dateStr: string | null): string => { + if (!dateStr) return "—"; + const date = new Date(dateStr); + return date.toLocaleString("es-MX", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const formatFullDate = (dateStr: string | null): string => { + if (!dateStr) return "Sin datos"; + const date = new Date(dateStr); + return date.toLocaleString("es-MX", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const exportToCSV = () => { + const headers = ["Fecha", "Medidor", "Serial", "Área", "Valor", "Tipo", "Batería", "Señal"]; + const rows = filteredReadings.map((r) => [ + formatFullDate(r.receivedAt), + r.meterName || "—", + r.meterSerialNumber || "—", + r.areaName || "—", + r.readingValue.toFixed(2), + r.readingType || "—", + r.batteryLevel !== null ? `${r.batteryLevel}%` : "—", + r.signalStrength !== null ? `${r.signalStrength} dBm` : "—", + ]); + const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `consumo_${new Date().toISOString().split("T")[0]}.csv`; + link.click(); + }; + + const clearFilters = () => { + setSelectedProject(""); + setStartDate(""); + setEndDate(""); + setSearch(""); + }; + + const hasFilters = selectedProject || startDate || endDate; + const activeFiltersCount = [selectedProject, startDate, endDate].filter(Boolean).length; + + return ( +
    +
    + {/* Header */} +
    +
    +

    Consumo de Agua

    +

    + Monitoreo en tiempo real de lecturas +

    +
    +
    + + +
    +
    + + {/* Stats Cards */} +
    + } + label="Total Lecturas" + value={summary?.totalReadings.toLocaleString() ?? "0"} + trend="+12%" + loading={loadingSummary} + gradient="from-blue-500 to-blue-600" + /> + } + label="Medidores Activos" + value={summary?.totalMeters.toLocaleString() ?? "0"} + loading={loadingSummary} + gradient="from-emerald-500 to-teal-600" + /> + } + label="Consumo Promedio" + value={`${summary?.avgReading.toFixed(1) ?? "0"} m³`} + loading={loadingSummary} + gradient="from-violet-500 to-purple-600" + /> + } + label="Última Lectura" + value={summary?.lastReadingDate ? formatDate(summary.lastReadingDate) : "Sin datos"} + loading={loadingSummary} + gradient="from-amber-500 to-orange-600" + /> +
    + + {/* Table Card */} +
    + {/* Table Header */} +
    +
    +
    + + setSearch(e.target.value)} + placeholder="Buscar lecturas..." + className="w-64 pl-10 pr-4 py-2 text-sm bg-slate-50 border-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:bg-white transition-all" + /> +
    + + + + {hasFilters && ( + + )} +
    + +
    + + {filteredReadings.length}{" "} + {pagination.total > filteredReadings.length && `de ${pagination.total} `} + lecturas + + + {pagination.totalPages > 1 && ( +
    + + + {pagination.page} / {pagination.totalPages} + + +
    + )} +
    +
    + + {/* Filters Panel */} + {showFilters && ( +
    +
    + + +
    + +
    + + setStartDate(e.target.value)} + className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20" + /> +
    + +
    + + setEndDate(e.target.value)} + className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20" + /> +
    +
    + )} + + {/* Table */} +
    + + + + + + + + + + + + + + {loadingReadings ? ( + Array.from({ length: 8 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + + )) + ) : filteredReadings.length === 0 ? ( + + + + ) : ( + filteredReadings.map((reading, idx) => ( + + + + + + + + + + )) + )} + +
    + Fecha + + Medidor + + Serial + + Área + + Consumo + + Tipo + + Estado +
    +
    +
    +
    +
    + +
    +

    No hay lecturas disponibles

    +

    + {hasFilters + ? "Intenta ajustar los filtros de búsqueda" + : "Las lecturas aparecerán aquí cuando se reciban datos"} +

    +
    +
    + {formatDate(reading.receivedAt)} + + + {reading.meterName || "—"} + + + + {reading.meterSerialNumber || "—"} + + + {reading.areaName || "—"} + + + {reading.readingValue.toFixed(2)} + + + + + +
    + + +
    +
    +
    +
    +
    +
    + ); +} + +function StatCard({ + icon, + label, + value, + trend, + loading, + gradient, +}: { + icon: React.ReactNode; + label: string; + value: string; + trend?: string; + loading?: boolean; + gradient: string; +}) { + return ( +
    +
    +
    +

    {label}

    + {loading ? ( +
    + ) : ( +

    {value}

    + )} + {trend && !loading && ( +
    + + {trend} +
    + )} +
    +
    + {icon} +
    +
    +
    +
    + ); +} + +function TypeBadge({ type }: { type: string | null }) { + if (!type) return ; + + const styles: Record = { + AUTOMATIC: { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" }, + MANUAL: { bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-500" }, + SCHEDULED: { bg: "bg-violet-50", text: "text-violet-700", dot: "bg-violet-500" }, + }; + + const style = styles[type] || { bg: "bg-slate-50", text: "text-slate-700", dot: "bg-slate-500" }; + + return ( + + + {type} + + ); +} + +function BatteryIndicator({ level }: { level: number | null }) { + if (level === null) return null; + + const getColor = () => { + if (level > 50) return "bg-emerald-500"; + if (level > 20) return "bg-amber-500"; + return "bg-red-500"; + }; + + return ( +
    +
    +
    +
    + {level}% +
    + ); +} + +function SignalIndicator({ strength }: { strength: number | null }) { + if (strength === null) return null; + + const getBars = () => { + if (strength >= -70) return 4; + if (strength >= -85) return 3; + if (strength >= -100) return 2; + return 1; + }; + + const bars = getBars(); + + return ( +
    + {[1, 2, 3, 4].map((i) => ( +
    + ))} +
    + ); +} diff --git a/src/pages/meters/MeterPage.tsx b/src/pages/meters/MeterPage.tsx index 981b14d..7eca26a 100644 --- a/src/pages/meters/MeterPage.tsx +++ b/src/pages/meters/MeterPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; -import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; -import type { Meter } from "../../api/meters"; +import { Plus, Trash2, Pencil, RefreshCcw, Upload } from "lucide-react"; +import type { Meter, MeterInput } from "../../api/meters"; import { createMeter, deleteMeter, updateMeter } from "../../api/meters"; import ConfirmModal from "../../components/layout/common/ConfirmModal"; @@ -8,16 +8,9 @@ import { useMeters } from "./useMeters"; import MetersSidebar from "./MetersSidebar"; import MetersTable from "./MetersTable"; import MetersModal from "./MetersModal"; +import MetersBulkUploadModal from "./MetersBulkUploadModal"; -/* ================= TYPES (exportables para otros componentes) ================= */ - -export interface DeviceData { - "Device ID": number; - "Device EUI": string; - "Join EUI": string; - AppKey: string; - meterId?: string; -} +/* ================= TYPES ================= */ export type ProjectStatus = "ACTIVO" | "INACTIVO"; @@ -34,20 +27,6 @@ export type ProjectCard = { export type TakeType = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES"; -/* ================= MOCKS (sin backend) ================= */ - -const MOCK_PROJECTS_BY_TYPE: Record< - Exclude, - Array<{ name: string; meters?: number }> -> = { - LORA: [ - { name: "LoRa - Demo 01", meters: 12 }, - { name: "LoRa - Demo 02", meters: 7 }, - ], - LORAWAN: [{ name: "LoRaWAN - Demo 01", meters: 4 }], - GRANDES: [{ name: "Grandes - Demo 01", meters: 2 }], -}; - /* ================= COMPONENT ================= */ export default function MetersPage({ @@ -68,46 +47,27 @@ export default function MetersPage({ const [confirmOpen, setConfirmOpen] = useState(false); const [deleting, setDeleting] = useState(false); - const emptyMeter: Omit = useMemo( + const [showBulkUpload, setShowBulkUpload] = useState(false); + + // Form state for creating/editing meters + const emptyForm: MeterInput = useMemo( () => ({ - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - areaName: "", - accountNumber: null, - userName: null, - userAddress: null, - meterSerialNumber: "", - meterName: "", - meterStatus: "Installed", - protocolType: "", - priceNo: null, - priceName: null, - dmaPartition: null, - supplyTypes: "", - deviceId: "", - deviceName: "", - deviceType: "", - usageAnalysisType: "", - installedTime: new Date().toISOString(), + serialNumber: "", + meterId: "", + name: "", + concentratorId: "", + location: "", + type: "LORA", + status: "ACTIVE", + installationDate: new Date().toISOString(), }), [] ); - const emptyDeviceData: DeviceData = useMemo( - () => ({ - "Device ID": 0, - "Device EUI": "", - "Join EUI": "", - AppKey: "", - }), - [] - ); - - const [form, setForm] = useState>(emptyMeter); - const [deviceForm, setDeviceForm] = useState(emptyDeviceData); + const [form, setForm] = useState(emptyForm); const [errors, setErrors] = useState>({}); - // Projects cards (real) + // Projects cards (from real data) const projectsDataReal: ProjectCard[] = useMemo(() => { const baseRegion = "Baja California"; const baseContact = "Operaciones"; @@ -121,32 +81,13 @@ export default function MetersPage({ activeAlerts: 0, lastSync: baseLastSync, contact: baseContact, - status: "ACTIVO", + status: "ACTIVO" as ProjectStatus, })); }, [m.allProjects, m.projectsCounts]); - // Projects cards (mock) - const projectsDataMock: ProjectCard[] = useMemo(() => { - const baseRegion = "Baja California"; - const baseContact = "Operaciones"; - const baseLastSync = "Hace 1 h"; + const sidebarProjects = isMockMode ? [] : projectsDataReal; - const mocks = MOCK_PROJECTS_BY_TYPE[takeType as Exclude] ?? []; - return mocks.map((x) => ({ - name: x.name, - region: baseRegion, - projects: 1, - meters: x.meters ?? 0, - activeAlerts: 0, - lastSync: baseLastSync, - contact: baseContact, - status: "ACTIVO", - })); - }, [takeType]); - - const sidebarProjects = isMockMode ? projectsDataMock : projectsDataReal; - - // Search filtered + // Search filtered meters const searchFiltered = useMemo(() => { if (isMockMode) return []; const q = search.trim().toLowerCase(); @@ -154,76 +95,43 @@ export default function MetersPage({ return m.filteredMeters.filter((x) => { return ( - (x.meterName ?? "").toLowerCase().includes(q) || - (x.meterSerialNumber ?? "").toLowerCase().includes(q) || - (x.deviceId ?? "").toLowerCase().includes(q) || - (x.areaName ?? "").toLowerCase().includes(q) + (x.name ?? "").toLowerCase().includes(q) || + (x.serialNumber ?? "").toLowerCase().includes(q) || + (x.location ?? "").toLowerCase().includes(q) || + (x.concentratorName ?? "").toLowerCase().includes(q) ); }); }, [isMockMode, search, m.filteredMeters]); - // Device config mock - const createOrUpdateDevice = async (deviceData: DeviceData): Promise => { - return new Promise((resolve) => { - setTimeout(() => { - console.log("Device data that would be sent to API:", deviceData); - resolve(); - }, 500); - }); - }; - // Validation const validateForm = (): boolean => { const next: Record = {}; - if (!form.meterName.trim()) next["meterName"] = true; - if (!form.meterSerialNumber.trim()) next["meterSerialNumber"] = true; - if (!form.areaName.trim()) next["areaName"] = true; - if (!form.deviceName.trim()) next["deviceName"] = true; - if (!form.protocolType.trim()) next["protocolType"] = true; - - if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) next["Device ID"] = true; - if (!deviceForm["Device EUI"].trim()) next["Device EUI"] = true; - if (!deviceForm["Join EUI"].trim()) next["Join EUI"] = true; - if (!deviceForm["AppKey"].trim()) next["AppKey"] = true; + if (!form.name.trim()) next["name"] = true; + if (!form.serialNumber.trim()) next["serialNumber"] = true; + if (!form.concentratorId.trim()) next["concentratorId"] = true; setErrors(next); return Object.keys(next).length === 0; }; - // CRUD + // CRUD handlers const handleSave = async () => { if (isMockMode) return; if (!validateForm()) return; try { - let savedMeter: Meter; - if (editingId) { - const meterToUpdate = m.meters.find((x) => x.id === editingId); - if (!meterToUpdate) throw new Error("Meter to update not found"); - const updatedMeter = await updateMeter(editingId, form); m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x))); - savedMeter = updatedMeter; } else { const newMeter = await createMeter(form); m.setMeters((prev) => [...prev, newMeter]); - savedMeter = newMeter; - } - - try { - const deviceDataWithRef = { ...deviceForm, meterId: savedMeter.id }; - await createOrUpdateDevice(deviceDataWithRef); - } catch (deviceError) { - console.error("Error saving device data:", deviceError); - alert("Meter saved, but there was an error saving device data."); } setShowModal(false); setEditingId(null); - setForm(emptyMeter); - setDeviceForm(emptyDeviceData); + setForm(emptyForm); setErrors({}); setActiveMeter(null); } catch (error) { @@ -260,6 +168,33 @@ export default function MetersPage({ setSearch(""); }; + const openEditModal = () => { + if (isMockMode || !activeMeter) return; + + setEditingId(activeMeter.id); + setForm({ + serialNumber: activeMeter.serialNumber, + meterId: activeMeter.meterId ?? "", + name: activeMeter.name, + concentratorId: activeMeter.concentratorId, + location: activeMeter.location ?? "", + type: activeMeter.type, + status: activeMeter.status, + installationDate: activeMeter.installationDate ?? "", + }); + setErrors({}); + setShowModal(true); + }; + + const openCreateModal = () => { + if (isMockMode) return; + + setForm(emptyForm); + setErrors({}); + setEditingId(null); + setShowModal(true); + }; + return (
    {/* SIDEBAR */} @@ -296,55 +231,23 @@ export default function MetersPage({
    - setEditingId(activeMeter.id); - setForm({ - createdAt: activeMeter.createdAt, - updatedAt: activeMeter.updatedAt, - areaName: activeMeter.areaName, - accountNumber: activeMeter.accountNumber, - userName: activeMeter.userName, - userAddress: activeMeter.userAddress, - meterSerialNumber: activeMeter.meterSerialNumber, - meterName: activeMeter.meterName, - meterStatus: activeMeter.meterStatus, - protocolType: activeMeter.protocolType, - priceNo: activeMeter.priceNo, - priceName: activeMeter.priceName, - dmaPartition: activeMeter.dmaPartition, - supplyTypes: activeMeter.supplyTypes, - deviceId: activeMeter.deviceId, - deviceName: activeMeter.deviceName, - deviceType: activeMeter.deviceType, - usageAnalysisType: activeMeter.usageAnalysisType, - installedTime: activeMeter.installedTime, - }); - setDeviceForm(emptyDeviceData); - setErrors({}); - setShowModal(true); - }} +
    ); } diff --git a/src/pages/meters/MetersBulkUploadModal.tsx b/src/pages/meters/MetersBulkUploadModal.tsx new file mode 100644 index 0000000..4128604 --- /dev/null +++ b/src/pages/meters/MetersBulkUploadModal.tsx @@ -0,0 +1,210 @@ +import { useState, useRef } from "react"; +import { Upload, Download, X, AlertCircle, CheckCircle } from "lucide-react"; +import { bulkUploadMeters, downloadMeterTemplate, type BulkUploadResult } from "../../api/meters"; + +type Props = { + onClose: () => void; + onSuccess: () => void; +}; + +export default function MetersBulkUploadModal({ onClose, onSuccess }: Props) { + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + // Validate file type + const validTypes = [ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel", + ]; + if (!validTypes.includes(selectedFile.type)) { + setError("Solo se permiten archivos Excel (.xlsx, .xls)"); + return; + } + setFile(selectedFile); + setError(null); + setResult(null); + } + }; + + const handleUpload = async () => { + if (!file) return; + + setUploading(true); + setError(null); + setResult(null); + + try { + const uploadResult = await bulkUploadMeters(file); + setResult(uploadResult); + + if (uploadResult.data.inserted > 0) { + onSuccess(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Error en la carga"); + } finally { + setUploading(false); + } + }; + + const handleDownloadTemplate = async () => { + try { + await downloadMeterTemplate(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error descargando plantilla"); + } + }; + + return ( +
    +
    +
    +

    Carga Masiva de Medidores

    + +
    + + {/* Instructions */} +
    +

    Instrucciones:

    +
      +
    1. Descarga la plantilla Excel con el formato correcto
    2. +
    3. Llena los datos de los medidores (serial_number, name y concentrator_serial son obligatorios)
    4. +
    5. El concentrator_serial debe coincidir con un concentrador existente
    6. +
    7. Sube el archivo Excel completado
    8. +
    +
    + + {/* Download Template Button */} + + + {/* File Input */} +
    + + + {file ? ( +
    + + {file.name} + +
    + ) : ( +
    + +

    + Arrastra un archivo Excel aquí o +

    + +
    + )} +
    + + {/* Error Message */} + {error && ( +
    + +

    {error}

    +
    + )} + + {/* Upload Result */} + {result && ( +
    +

    + {result.success ? "Carga completada" : "Carga completada con errores"} +

    +
    +

    Total de filas: {result.data.totalRows}

    +

    Insertados: {result.data.inserted}

    + {result.data.failed > 0 && ( +

    Fallidos: {result.data.failed}

    + )} +
    + + {/* Error Details */} + {result.data.errors.length > 0 && ( +
    +
    Errores:
    +
    + {result.data.errors.map((err, idx) => ( +
    + Fila {err.row}: {err.error} +
    + ))} +
    +
    + )} +
    + )} + + {/* Actions */} +
    + + {!result && ( + + )} +
    +
    +
    + ); +} diff --git a/src/pages/meters/MetersModal.tsx b/src/pages/meters/MetersModal.tsx index 55f0f55..32e460b 100644 --- a/src/pages/meters/MetersModal.tsx +++ b/src/pages/meters/MetersModal.tsx @@ -1,15 +1,13 @@ import type React from "react"; -import type { Meter } from "../../api/meters"; -import type { DeviceData } from "./MeterPage"; +import { useEffect, useState } from "react"; +import type { MeterInput } from "../../api/meters"; +import { fetchConcentrators, type Concentrator } from "../../api/concentrators"; type Props = { editingId: string | null; - form: Omit; - setForm: React.Dispatch>>; - - deviceForm: DeviceData; - setDeviceForm: React.Dispatch>; + form: MeterInput; + setForm: React.Dispatch>; errors: Record; setErrors: React.Dispatch>>; @@ -22,245 +20,183 @@ export default function MetersModal({ editingId, form, setForm, - deviceForm, - setDeviceForm, errors, setErrors, onClose, onSave, }: Props) { - const title = editingId ? "Edit Meter" : "Add Meter"; + const title = editingId ? "Editar Medidor" : "Agregar Medidor"; + const [concentrators, setConcentrators] = useState([]); + const [loadingConcentrators, setLoadingConcentrators] = useState(true); + + // Load concentrators for the dropdown + useEffect(() => { + const load = async () => { + try { + const data = await fetchConcentrators(); + setConcentrators(data); + } catch (error) { + console.error("Error loading concentrators:", error); + } finally { + setLoadingConcentrators(false); + } + }; + load(); + }, []); return (
    -
    +

    {title}

    {/* FORM */}

    - Meter Information + Información del Medidor

    + { - setForm({ ...form, areaName: e.target.value }); - if (errors["areaName"]) setErrors({ ...errors, areaName: false }); + setForm({ ...form, serialNumber: e.target.value }); + if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false }); }} required /> - {errors["areaName"] &&

    This field is required

    } -
    - -
    - - setForm({ ...form, accountNumber: e.target.value || null }) - } - /> -
    -
    - -
    -
    - - setForm({ ...form, userName: e.target.value || null }) - } - /> -
    - -
    - - setForm({ ...form, userAddress: e.target.value || null }) - } - /> -
    -
    - -
    -
    - { - setForm({ ...form, meterSerialNumber: e.target.value }); - if (errors["meterSerialNumber"]) - setErrors({ ...errors, meterSerialNumber: false }); - }} - required - /> - {errors["meterSerialNumber"] && ( -

    This field is required

    + {errors["serialNumber"] && ( +

    Campo requerido

    )}
    - { - setForm({ ...form, meterName: e.target.value }); - if (errors["meterName"]) setErrors({ ...errors, meterName: false }); - }} - required - /> - {errors["meterName"] &&

    This field is required

    } -
    -
    - -
    -
    - { - setForm({ ...form, protocolType: e.target.value }); - if (errors["protocolType"]) setErrors({ ...errors, protocolType: false }); - }} - required - /> - {errors["protocolType"] &&

    This field is required

    } -
    - -
    + setForm({ ...form, deviceId: e.target.value || "" })} + placeholder="ID del medidor (opcional)" + value={form.meterId ?? ""} + onChange={(e) => setForm({ ...form, meterId: e.target.value || undefined })} />
    + { - setForm({ ...form, deviceName: e.target.value }); - if (errors["deviceName"]) setErrors({ ...errors, deviceName: false }); + setForm({ ...form, name: e.target.value }); + if (errors["name"]) setErrors({ ...errors, name: false }); }} required /> - {errors["deviceName"] &&

    This field is required

    } + {errors["name"] &&

    Campo requerido

    }
    -
    - {/* DEVICE CONFIG */} -
    -

    - Device Configuration -

    +
    + + + {errors["concentratorId"] && ( +

    Selecciona un concentrador

    + )} +
    + +
    + + setForm({ ...form, location: e.target.value || undefined })} + /> +
    - { - setDeviceForm({ ...deviceForm, "Device ID": parseInt(e.target.value) || 0 }); - if (errors["Device ID"]) setErrors({ ...errors, "Device ID": false }); - }} - required - min={1} - /> - {errors["Device ID"] &&

    This field is required

    } + +
    - { - setDeviceForm({ ...deviceForm, "Device EUI": e.target.value }); - if (errors["Device EUI"]) setErrors({ ...errors, "Device EUI": false }); - }} - required - /> - {errors["Device EUI"] &&

    This field is required

    } + +
    + { - setDeviceForm({ ...deviceForm, "Join EUI": e.target.value }); - if (errors["Join EUI"]) setErrors({ ...errors, "Join EUI": false }); - }} - required + type="date" + className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent" + value={form.installationDate?.split("T")[0] ?? ""} + onChange={(e) => + setForm({ + ...form, + installationDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, + }) + } /> - {errors["Join EUI"] &&

    This field is required

    } -
    - -
    - { - setDeviceForm({ ...deviceForm, AppKey: e.target.value }); - if (errors["AppKey"]) setErrors({ ...errors, AppKey: false }); - }} - required - /> - {errors["AppKey"] &&

    This field is required

    }
    {/* ACTIONS */}
    diff --git a/src/pages/meters/MetersTable.tsx b/src/pages/meters/MetersTable.tsx index 9c5ea82..2582651 100644 --- a/src/pages/meters/MetersTable.tsx +++ b/src/pages/meters/MetersTable.tsx @@ -28,15 +28,51 @@ export default function MetersTable({ title="Meters" isLoading={isLoading} columns={[ - { title: "Area Name", field: "areaName", render: (r: any) => r.areaName || "-" }, - { title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" }, - { title: "User Name", field: "userName", render: (r: any) => r.userName || "-" }, - { title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" }, - { title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" }, - { title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" }, - { title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" }, - { title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" }, - { title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" }, + { title: "Serial", field: "serialNumber", render: (r: Meter) => r.serialNumber || "-" }, + { title: "Meter ID", field: "meterId", render: (r: Meter) => r.meterId || "-" }, + { title: "Nombre", field: "name", render: (r: Meter) => r.name || "-" }, + { title: "Ubicación", field: "location", render: (r: Meter) => r.location || "-" }, + { + title: "Tipo", + field: "type", + render: (r: Meter) => { + const typeLabels: Record = { + LORA: "LoRa", + LORAWAN: "LoRaWAN", + GRANDES: "Grandes Consumidores", + }; + const typeColors: Record = { + LORA: "text-green-600 border-green-600", + LORAWAN: "text-purple-600 border-purple-600", + GRANDES: "text-orange-600 border-orange-600", + }; + const type = r.type || "LORA"; + return ( + + {typeLabels[type] || type} + + ); + }, + }, + { + title: "Estado", + field: "status", + render: (r: Meter) => ( + + {r.status || "-"} + + ), + }, + { title: "Concentrador", field: "concentratorName", render: (r: Meter) => r.concentratorName || "-" }, + { title: "Última Lectura", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue?.toFixed(2) ?? "-" }, ]} data={disabled ? [] : data} onRowClick={(_, rowData) => onRowClick(rowData as Meter)} diff --git a/src/pages/meters/useMeters.ts b/src/pages/meters/useMeters.ts index 6ab8641..89c0365 100644 --- a/src/pages/meters/useMeters.ts +++ b/src/pages/meters/useMeters.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { fetchMeters, type Meter } from "../../api/meters"; +import { fetchProjects } from "../../api/projects"; type UseMetersArgs = { initialProject?: string; @@ -15,37 +16,43 @@ export function useMeters({ initialProject }: UseMetersArgs) { const [filteredMeters, setFilteredMeters] = useState([]); const [loadingMeters, setLoadingMeters] = useState(true); - const loadMeters = async () => { - setLoadingMeters(true); + const loadProjects = async () => { setLoadingProjects(true); - try { - const data = await fetchMeters(); - - const projectsArray = [...new Set(data.map((r) => r.areaName))] - .filter(Boolean) as string[]; - - setAllProjects(projectsArray); - setMeters(data); + const projects = await fetchProjects(); + const projectNames = projects.map((p) => p.name); + setAllProjects(projectNames); setSelectedProject((prev) => { if (prev) return prev; if (initialProject) return initialProject; - return projectsArray[0] ?? ""; + return projectNames[0] ?? ""; }); } catch (error) { - console.error("Error loading meters:", error); + console.error("Error loading projects:", error); setAllProjects([]); - setMeters([]); - setSelectedProject(""); } finally { - setLoadingMeters(false); setLoadingProjects(false); } }; - // init + const loadMeters = async () => { + setLoadingMeters(true); + + try { + const data = await fetchMeters(); + setMeters(data); + } catch (error) { + console.error("Error loading meters:", error); + setMeters([]); + } finally { + setLoadingMeters(false); + } + }; + + // init - load projects and meters useEffect(() => { + loadProjects(); loadMeters(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -61,13 +68,13 @@ export function useMeters({ initialProject }: UseMetersArgs) { setFilteredMeters([]); return; } - setFilteredMeters(meters.filter((m) => m.areaName === selectedProject)); + setFilteredMeters(meters.filter((m) => m.projectName === selectedProject)); }, [selectedProject, meters]); const projectsCounts = useMemo(() => { return meters.reduce>((acc, m) => { - const area = m.areaName ?? "SIN PROYECTO"; - acc[area] = (acc[area] ?? 0) + 1; + const project = m.projectName ?? "SIN PROYECTO"; + acc[project] = (acc[project] ?? 0) + 1; return acc; }, {}); }, [meters]); diff --git a/src/pages/projects/ProjectsPage.tsx b/src/pages/projects/ProjectsPage.tsx index 87e5c9b..b7a037d 100644 --- a/src/pages/projects/ProjectsPage.tsx +++ b/src/pages/projects/ProjectsPage.tsx @@ -3,13 +3,13 @@ import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import MaterialTable from "@material-table/core"; import { Project, + ProjectInput, fetchProjects, createProject as apiCreateProject, updateProject as apiUpdateProject, deleteProject as apiDeleteProject, } from "../../api/projects"; -/* ================= COMPONENT ================= */ export default function ProjectsPage() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); @@ -19,20 +19,16 @@ export default function ProjectsPage() { const [showModal, setShowModal] = useState(false); const [editingId, setEditingId] = useState(null); - const emptyProject: Omit = { + const emptyForm: ProjectInput = { + name: "", + description: "", areaName: "", - deviceSN: "", - deviceName: "", - deviceType: "", - deviceStatus: "ACTIVE", - operator: "", - installedTime: "", - communicationTime: "", + location: "", + status: "ACTIVE", }; - const [form, setForm] = useState>(emptyProject); + const [form, setForm] = useState(emptyForm); - /* ================= LOAD ================= */ const loadProjects = async () => { setLoading(true); try { @@ -50,7 +46,6 @@ export default function ProjectsPage() { loadProjects(); }, []); - const handleSave = async () => { try { if (editingId) { @@ -65,7 +60,7 @@ export default function ProjectsPage() { setShowModal(false); setEditingId(null); - setForm(emptyProject); + setForm(emptyForm); setActiveProject(null); } catch (error) { console.error("Error saving project:", error); @@ -81,7 +76,7 @@ export default function ProjectsPage() { if (!activeProject) return; const confirmDelete = window.confirm( - `Are you sure you want to delete the project "${activeProject.deviceName}"?` + `¿Estás seguro que quieres eliminar el proyecto "${activeProject.name}"?` ); if (!confirmDelete) return; @@ -100,14 +95,31 @@ export default function ProjectsPage() { } }; - /* ================= FILTER ================= */ + const openEditModal = () => { + if (!activeProject) return; + setEditingId(activeProject.id); + setForm({ + name: activeProject.name, + description: activeProject.description ?? "", + areaName: activeProject.areaName, + location: activeProject.location ?? "", + status: activeProject.status, + }); + setShowModal(true); + }; + + const openCreateModal = () => { + setForm(emptyForm); + setEditingId(null); + setShowModal(true); + }; + const filtered = projects.filter((p) => - `${p.areaName} ${p.deviceName} ${p.deviceSN}` + `${p.name} ${p.areaName} ${p.description ?? ""}` .toLowerCase() .includes(search.toLowerCase()) ); - /* ================= UI ================= */ return (
    @@ -120,41 +132,23 @@ export default function ProjectsPage() { >

    Project Management

    -

    Projects registered

    +

    Proyectos registrados

    @@ -177,38 +171,40 @@ export default function ProjectsPage() { {/* SEARCH */} setSearch(e.target.value)} /> {/* TABLE */} rowData.description || "-" }, + { title: "Ubicación", field: "location", render: (rowData: Project) => rowData.location || "-" }, { - title: "Status", - field: "deviceStatus", - render: (rowData) => ( + title: "Estado", + field: "status", + render: (rowData: Project) => ( - {rowData.deviceStatus} + {rowData.status} ), }, - { title: "Operator", field: "operator" }, - { title: "Installed Time", field: "installedTime" }, - { title: "Communication Name", field: "communicationTime" }, + { + title: "Creado", + field: "createdAt", + render: (rowData: Project) => new Date(rowData.createdAt).toLocaleDateString(), + }, ]} data={filtered} onRowClick={(_, rowData) => setActiveProject(rowData as Project)} @@ -226,8 +222,8 @@ export default function ProjectsPage() { localization={{ body: { emptyDataSourceMessage: loading - ? "Loading projects..." - : "No projects found. Click 'Add' to create your first project.", + ? "Cargando proyectos..." + : "No hay proyectos. Haz clic en 'Agregar' para crear uno.", }, }} /> @@ -235,85 +231,78 @@ export default function ProjectsPage() { {/* MODAL */} {showModal && ( -
    -
    +
    +

    - {editingId ? "Edit Project" : "Add Project"} + {editingId ? "Editar Proyecto" : "Agregar Proyecto"}

    - setForm({ ...form, areaName: e.target.value })} - /> +
    + + setForm({ ...form, name: e.target.value })} + /> +
    - setForm({ ...form, deviceSN: e.target.value })} - /> +
    + + setForm({ ...form, areaName: e.target.value })} + /> +
    - setForm({ ...form, deviceName: e.target.value })} - /> +
    + +