Improve the frontend 5
This commit is contained in:
120
frontend/src/components/analytics/AnalyticsCard.tsx
Normal file
120
frontend/src/components/analytics/AnalyticsCard.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card } from '../ui';
|
||||
|
||||
export interface AnalyticsCardProps {
|
||||
/**
|
||||
* Card title
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Card subtitle/description
|
||||
*/
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Card content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Custom className
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Action buttons for card header
|
||||
*/
|
||||
actions?: React.ReactNode;
|
||||
/**
|
||||
* Loading state
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Empty state message
|
||||
*/
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* Whether the card has data
|
||||
*/
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnalyticsCard - Preset Card component for analytics pages
|
||||
*
|
||||
* Provides consistent styling and structure for analytics content cards:
|
||||
* - Standard padding (p-6)
|
||||
* - Rounded corners (rounded-lg)
|
||||
* - Title with consistent styling (text-lg font-semibold mb-4)
|
||||
* - Optional subtitle
|
||||
* - Optional header actions
|
||||
* - Loading state support
|
||||
* - Empty state support
|
||||
*/
|
||||
export const AnalyticsCard: React.FC<AnalyticsCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className,
|
||||
actions,
|
||||
loading = false,
|
||||
emptyMessage,
|
||||
isEmpty = false,
|
||||
}) => {
|
||||
return (
|
||||
<Card className={clsx('p-6', className)}>
|
||||
{/* Card Header */}
|
||||
{(title || subtitle || actions) && (
|
||||
<div className="mb-4">
|
||||
{/* Title Row */}
|
||||
{(title || actions) && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtitle */}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)] mb-3"></div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Cargando datos...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<svg
|
||||
className="w-12 h-12 text-[var(--text-tertiary)] mb-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{emptyMessage || 'No hay datos disponibles'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Content */}
|
||||
{!loading && !isEmpty && children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
219
frontend/src/components/analytics/AnalyticsPageLayout.tsx
Normal file
219
frontend/src/components/analytics/AnalyticsPageLayout.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { PageHeader } from '../layout';
|
||||
import { Card, Button, StatsGrid, Tabs } from '../ui';
|
||||
import { ActionButton } from '../layout/PageHeader/PageHeader';
|
||||
|
||||
export interface AnalyticsPageLayoutProps {
|
||||
/**
|
||||
* Page title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Page description
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Action buttons for the page header
|
||||
*/
|
||||
actions?: ActionButton[];
|
||||
/**
|
||||
* Key metrics to display in stats grid
|
||||
*/
|
||||
stats?: Array<{
|
||||
title: string;
|
||||
value: number | string;
|
||||
variant?: 'success' | 'error' | 'warning' | 'info';
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
subtitle?: string;
|
||||
formatter?: (value: any) => string;
|
||||
}>;
|
||||
/**
|
||||
* Number of columns for stats grid (4 or 6)
|
||||
*/
|
||||
statsColumns?: 4 | 6;
|
||||
/**
|
||||
* Tab configuration
|
||||
*/
|
||||
tabs?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}>;
|
||||
/**
|
||||
* Active tab ID
|
||||
*/
|
||||
activeTab?: string;
|
||||
/**
|
||||
* Tab change handler
|
||||
*/
|
||||
onTabChange?: (tabId: string) => void;
|
||||
/**
|
||||
* Optional filters/controls section
|
||||
*/
|
||||
filters?: React.ReactNode;
|
||||
/**
|
||||
* Loading state for subscription check
|
||||
*/
|
||||
subscriptionLoading?: boolean;
|
||||
/**
|
||||
* Whether user has access to advanced analytics
|
||||
*/
|
||||
hasAccess?: boolean;
|
||||
/**
|
||||
* Loading state for data
|
||||
*/
|
||||
dataLoading?: boolean;
|
||||
/**
|
||||
* Main content (tab content)
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Custom className for container
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Show mobile optimization notice
|
||||
*/
|
||||
showMobileNotice?: boolean;
|
||||
/**
|
||||
* Custom mobile notice text
|
||||
*/
|
||||
mobileNoticeText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnalyticsPageLayout - Standardized layout for analytics pages
|
||||
*
|
||||
* Provides consistent structure across all analytics pages:
|
||||
* 1. Page header with title, description, and actions
|
||||
* 2. Optional filters/controls section
|
||||
* 3. Key metrics (StatsGrid with 4 or 6 metrics)
|
||||
* 4. Tab navigation
|
||||
* 5. Tab content area
|
||||
* 6. Subscription checks and access control
|
||||
* 7. Loading states
|
||||
*/
|
||||
export const AnalyticsPageLayout: React.FC<AnalyticsPageLayoutProps> = ({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
stats,
|
||||
statsColumns = 4,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
filters,
|
||||
subscriptionLoading = false,
|
||||
hasAccess = true,
|
||||
dataLoading = false,
|
||||
children,
|
||||
className,
|
||||
showMobileNotice = false,
|
||||
mobileNoticeText,
|
||||
}) => {
|
||||
// Show loading state while subscription data is being fetched
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title} description={description} />
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Cargando información de suscripción...
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user doesn't have access to advanced analytics, show upgrade message
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title} description={description} />
|
||||
<Card className="p-8 text-center">
|
||||
<Lock className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
Funcionalidad Exclusiva para Profesionales y Empresas
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
El análisis avanzado está disponible solo para planes Professional y Enterprise.
|
||||
Actualiza tu plan para acceder a métricas avanzadas, análisis detallados y optimización operativa.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => (window.location.hash = '#/app/settings/profile')}
|
||||
>
|
||||
Actualizar Plan
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-6', className)}>
|
||||
{/* Page Header */}
|
||||
<PageHeader title={title} description={description} actions={actions} />
|
||||
|
||||
{/* Optional Filters/Controls */}
|
||||
{filters && <Card className="p-6">{filters}</Card>}
|
||||
|
||||
{/* Key Metrics - StatsGrid */}
|
||||
{stats && stats.length > 0 && (
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
columns={statsColumns}
|
||||
loading={dataLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
{tabs && tabs.length > 0 && activeTab && onTabChange && (
|
||||
<Tabs
|
||||
items={tabs.map((tab) => ({ id: tab.id, label: tab.label }))}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content (Tab Content) */}
|
||||
<div className="space-y-6">{children}</div>
|
||||
|
||||
{/* Mobile Optimization Notice */}
|
||||
{showMobileNotice && (
|
||||
<div className="md:hidden p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="w-5 h-5 mt-0.5 text-blue-600 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
Experiencia Optimizada para Móvil
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{mobileNoticeText ||
|
||||
'Desliza, desplázate e interactúa con los gráficos para explorar los datos.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
frontend/src/components/analytics/events/ActionBadge.tsx
Normal file
63
frontend/src/components/analytics/events/ActionBadge.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '../../ui';
|
||||
import { Plus, Edit, Trash2, Check, X, Eye, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface ActionBadgeProps {
|
||||
action: string;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export const ActionBadge: React.FC<ActionBadgeProps> = ({ action, showIcon = true }) => {
|
||||
const actionConfig: Record<string, { label: string; color: 'green' | 'blue' | 'red' | 'purple' | 'orange' | 'gray'; icon: any }> = {
|
||||
create: {
|
||||
label: 'Crear',
|
||||
color: 'green',
|
||||
icon: Plus,
|
||||
},
|
||||
update: {
|
||||
label: 'Actualizar',
|
||||
color: 'blue',
|
||||
icon: Edit,
|
||||
},
|
||||
delete: {
|
||||
label: 'Eliminar',
|
||||
color: 'red',
|
||||
icon: Trash2,
|
||||
},
|
||||
approve: {
|
||||
label: 'Aprobar',
|
||||
color: 'green',
|
||||
icon: Check,
|
||||
},
|
||||
reject: {
|
||||
label: 'Rechazar',
|
||||
color: 'red',
|
||||
icon: X,
|
||||
},
|
||||
view: {
|
||||
label: 'Ver',
|
||||
color: 'gray',
|
||||
icon: Eye,
|
||||
},
|
||||
sync: {
|
||||
label: 'Sincronizar',
|
||||
color: 'purple',
|
||||
icon: RefreshCw,
|
||||
},
|
||||
};
|
||||
|
||||
const config = actionConfig[action.toLowerCase()] || {
|
||||
label: action,
|
||||
color: 'gray' as const,
|
||||
icon: RefreshCw,
|
||||
};
|
||||
|
||||
const { label, color, icon: Icon } = config;
|
||||
|
||||
return (
|
||||
<Badge color={color}>
|
||||
{showIcon && <Icon className="mr-1 h-3 w-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
194
frontend/src/components/analytics/events/EventDetailModal.tsx
Normal file
194
frontend/src/components/analytics/events/EventDetailModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import { X, Copy, Download, User, Clock, Globe, Terminal } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../ui';
|
||||
import { AggregatedAuditLog } from '../../../api/types/auditLogs';
|
||||
import { SeverityBadge } from './SeverityBadge';
|
||||
import { ServiceBadge } from './ServiceBadge';
|
||||
import { ActionBadge } from './ActionBadge';
|
||||
|
||||
interface EventDetailModalProps {
|
||||
event: AggregatedAuditLog;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EventDetailModal: React.FC<EventDetailModalProps> = ({ event, onClose }) => {
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const exportEvent = () => {
|
||||
const json = JSON.stringify(event, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `event-${event.id}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<Card className="max-h-[90vh] w-full max-w-4xl overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Detalle del Evento</h2>
|
||||
<ServiceBadge service={event.service_name} />
|
||||
<SeverityBadge severity={event.severity} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{new Date(event.created_at).toLocaleString()}
|
||||
</span>
|
||||
{event.user_id && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
{event.user_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(event.id)}
|
||||
icon={Copy}
|
||||
title="Copiar ID"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={exportEvent}
|
||||
icon={Download}
|
||||
title="Exportar evento"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
icon={X}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Event Information */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900">Información del Evento</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600">Acción</label>
|
||||
<div className="mt-1">
|
||||
<ActionBadge action={event.action} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600">Tipo de Recurso</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{event.resource_type}</p>
|
||||
</div>
|
||||
{event.resource_id && (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-600">ID de Recurso</label>
|
||||
<p className="mt-1 font-mono text-sm text-gray-900">{event.resource_id}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-600">Descripción</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{event.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changes */}
|
||||
{event.changes && Object.keys(event.changes).length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900">Cambios</h3>
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<pre className="overflow-x-auto text-sm text-gray-900">
|
||||
{JSON.stringify(event.changes, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request Metadata */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900">Metadatos de Solicitud</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{event.endpoint && (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-600">Endpoint</label>
|
||||
<p className="mt-1 font-mono text-sm text-gray-900">{event.endpoint}</p>
|
||||
</div>
|
||||
)}
|
||||
{event.method && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600">Método HTTP</label>
|
||||
<Badge color="blue">{event.method}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{event.ip_address && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600">Dirección IP</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{event.ip_address}</p>
|
||||
</div>
|
||||
)}
|
||||
{event.user_agent && (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-600">User Agent</label>
|
||||
<p className="mt-1 text-sm text-gray-700 break-all">{event.user_agent}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Metadata */}
|
||||
{event.audit_metadata && Object.keys(event.audit_metadata).length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900">Metadatos Adicionales</h3>
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<pre className="overflow-x-auto text-sm text-gray-900">
|
||||
{JSON.stringify(event.audit_metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event ID */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-600">ID del Evento</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-sm text-gray-900">
|
||||
{event.id}
|
||||
</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(event.id)}
|
||||
icon={Copy}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
174
frontend/src/components/analytics/events/EventFilterSidebar.tsx
Normal file
174
frontend/src/components/analytics/events/EventFilterSidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Button } from '../../ui';
|
||||
import { Calendar, User, Filter as FilterIcon, X } from 'lucide-react';
|
||||
import { AuditLogFilters, AuditLogStatsResponse, AUDIT_LOG_SERVICES } from '../../../api/types/auditLogs';
|
||||
|
||||
interface EventFilterSidebarProps {
|
||||
filters: AuditLogFilters;
|
||||
onFiltersChange: (filters: Partial<AuditLogFilters>) => void;
|
||||
stats?: AuditLogStatsResponse;
|
||||
}
|
||||
|
||||
export const EventFilterSidebar: React.FC<EventFilterSidebarProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
stats,
|
||||
}) => {
|
||||
const [localFilters, setLocalFilters] = useState<AuditLogFilters>(filters);
|
||||
|
||||
const handleApply = () => {
|
||||
onFiltersChange(localFilters);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
const clearedFilters: AuditLogFilters = { limit: 50, offset: 0 };
|
||||
setLocalFilters(clearedFilters);
|
||||
onFiltersChange(clearedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filtros</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
icon={X}
|
||||
>
|
||||
Limpiar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Date Range */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Rango de Fechas
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="date"
|
||||
value={localFilters.start_date?.split('T')[0] || ''}
|
||||
onChange={(e) =>
|
||||
setLocalFilters({ ...localFilters, start_date: e.target.value ? new Date(e.target.value).toISOString() : undefined })
|
||||
}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Fecha inicio"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={localFilters.end_date?.split('T')[0] || ''}
|
||||
onChange={(e) =>
|
||||
setLocalFilters({ ...localFilters, end_date: e.target.value ? new Date(e.target.value).toISOString() : undefined })
|
||||
}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Fecha fin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Severidad
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.severity || ''}
|
||||
onChange={(e) =>
|
||||
setLocalFilters({ ...localFilters, severity: e.target.value as any || undefined })
|
||||
}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
<option value="low">Bajo</option>
|
||||
<option value="medium">Medio</option>
|
||||
<option value="high">Alto</option>
|
||||
<option value="critical">Crítico</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Filter */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Acción
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localFilters.action || ''}
|
||||
onChange={(e) =>
|
||||
setLocalFilters({ ...localFilters, action: e.target.value || undefined })
|
||||
}
|
||||
placeholder="create, update, delete..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Type Filter */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Tipo de Recurso
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localFilters.resource_type || ''}
|
||||
onChange={(e) =>
|
||||
setLocalFilters({ ...localFilters, resource_type: e.target.value || undefined })
|
||||
}
|
||||
placeholder="user, recipe, order..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Buscar en Descripción
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localFilters.search || ''}
|
||||
onChange={(e) =>
|
||||
setLocalFilters({ ...localFilters, search: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Buscar..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleApply}
|
||||
icon={FilterIcon}
|
||||
>
|
||||
Aplicar Filtros
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{stats && (
|
||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Resumen</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Eventos:</span>
|
||||
<span className="font-semibold text-gray-900">{stats.total_events}</span>
|
||||
</div>
|
||||
{stats.events_by_severity && Object.keys(stats.events_by_severity).length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Críticos:</span>
|
||||
<span className="font-semibold text-red-600">
|
||||
{stats.events_by_severity.critical || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
104
frontend/src/components/analytics/events/EventStatsWidget.tsx
Normal file
104
frontend/src/components/analytics/events/EventStatsWidget.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Card } from '../../ui';
|
||||
import { Activity, AlertTriangle, TrendingUp, Clock } from 'lucide-react';
|
||||
import { AuditLogStatsResponse } from '../../../api/types/auditLogs';
|
||||
|
||||
interface EventStatsWidgetProps {
|
||||
stats: AuditLogStatsResponse;
|
||||
}
|
||||
|
||||
export const EventStatsWidget: React.FC<EventStatsWidgetProps> = ({ stats }) => {
|
||||
const criticalCount = stats.events_by_severity?.critical || 0;
|
||||
const highCount = stats.events_by_severity?.high || 0;
|
||||
const todayCount = stats.total_events; // Simplified - would need date filtering for actual "today"
|
||||
|
||||
// Find most common action
|
||||
const mostCommonAction = Object.entries(stats.events_by_action || {})
|
||||
.sort(([, a], [, b]) => b - a)[0]?.[0] || 'N/A';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total Events */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total de Eventos</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900">{stats.total_events}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-blue-100 p-3">
|
||||
<Activity className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Critical Events */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Eventos Críticos</p>
|
||||
<p className="mt-2 text-3xl font-bold text-red-600">{criticalCount}</p>
|
||||
{highCount > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">+{highCount} de alta prioridad</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-full bg-red-100 p-3">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Most Common Action */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Acción Más Común</p>
|
||||
<p className="mt-2 text-2xl font-bold text-gray-900 capitalize">
|
||||
{mostCommonAction}
|
||||
</p>
|
||||
{stats.events_by_action && stats.events_by_action[mostCommonAction] && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{stats.events_by_action[mostCommonAction]} veces
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-full bg-green-100 p-3">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Date Range */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Período</p>
|
||||
{stats.date_range.min && stats.date_range.max ? (
|
||||
<>
|
||||
<p className="mt-2 text-sm font-semibold text-gray-900">
|
||||
{new Date(stats.date_range.min).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">hasta</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{new Date(stats.date_range.max).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-500">Sin datos</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-full bg-purple-100 p-3">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
frontend/src/components/analytics/events/ServiceBadge.tsx
Normal file
95
frontend/src/components/analytics/events/ServiceBadge.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '../../ui';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Package,
|
||||
ClipboardList,
|
||||
Factory,
|
||||
ChefHat,
|
||||
Truck,
|
||||
CreditCard,
|
||||
Brain,
|
||||
Bell,
|
||||
Cloud,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ServiceBadgeProps {
|
||||
service: string;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export const ServiceBadge: React.FC<ServiceBadgeProps> = ({ service, showIcon = true }) => {
|
||||
const serviceConfig: Record<string, { label: string; color: 'blue' | 'green' | 'purple' | 'orange' | 'pink' | 'indigo' | 'teal' | 'cyan' | 'amber'; icon: any }> = {
|
||||
sales: {
|
||||
label: 'Ventas',
|
||||
color: 'blue',
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
inventory: {
|
||||
label: 'Inventario',
|
||||
color: 'green',
|
||||
icon: Package,
|
||||
},
|
||||
orders: {
|
||||
label: 'Pedidos',
|
||||
color: 'purple',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
production: {
|
||||
label: 'Producción',
|
||||
color: 'orange',
|
||||
icon: Factory,
|
||||
},
|
||||
recipes: {
|
||||
label: 'Recetas',
|
||||
color: 'pink',
|
||||
icon: ChefHat,
|
||||
},
|
||||
suppliers: {
|
||||
label: 'Proveedores',
|
||||
color: 'indigo',
|
||||
icon: Truck,
|
||||
},
|
||||
pos: {
|
||||
label: 'POS',
|
||||
color: 'teal',
|
||||
icon: CreditCard,
|
||||
},
|
||||
training: {
|
||||
label: 'Entrenamiento',
|
||||
color: 'cyan',
|
||||
icon: Brain,
|
||||
},
|
||||
notification: {
|
||||
label: 'Notificaciones',
|
||||
color: 'amber',
|
||||
icon: Bell,
|
||||
},
|
||||
external: {
|
||||
label: 'Externo',
|
||||
color: 'blue',
|
||||
icon: Cloud,
|
||||
},
|
||||
forecasting: {
|
||||
label: 'Pronósticos',
|
||||
color: 'purple',
|
||||
icon: TrendingUp,
|
||||
},
|
||||
};
|
||||
|
||||
const config = serviceConfig[service] || {
|
||||
label: service,
|
||||
color: 'gray' as const,
|
||||
icon: Package,
|
||||
};
|
||||
|
||||
const { label, color, icon: Icon } = config;
|
||||
|
||||
return (
|
||||
<Badge color={color}>
|
||||
{showIcon && <Icon className="mr-1 h-3 w-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
42
frontend/src/components/analytics/events/SeverityBadge.tsx
Normal file
42
frontend/src/components/analytics/events/SeverityBadge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '../../ui';
|
||||
import { AlertTriangle, Info, AlertCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface SeverityBadgeProps {
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export const SeverityBadge: React.FC<SeverityBadgeProps> = ({ severity, showIcon = true }) => {
|
||||
const config = {
|
||||
low: {
|
||||
label: 'Bajo',
|
||||
color: 'gray' as const,
|
||||
icon: Info,
|
||||
},
|
||||
medium: {
|
||||
label: 'Medio',
|
||||
color: 'blue' as const,
|
||||
icon: AlertCircle,
|
||||
},
|
||||
high: {
|
||||
label: 'Alto',
|
||||
color: 'orange' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
critical: {
|
||||
label: 'Crítico',
|
||||
color: 'red' as const,
|
||||
icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const { label, color, icon: Icon } = config[severity];
|
||||
|
||||
return (
|
||||
<Badge color={color}>
|
||||
{showIcon && <Icon className="mr-1 h-3 w-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
6
frontend/src/components/analytics/events/index.ts
Normal file
6
frontend/src/components/analytics/events/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { EventFilterSidebar } from './EventFilterSidebar';
|
||||
export { EventDetailModal } from './EventDetailModal';
|
||||
export { EventStatsWidget } from './EventStatsWidget';
|
||||
export { SeverityBadge } from './SeverityBadge';
|
||||
export { ServiceBadge } from './ServiceBadge';
|
||||
export { ActionBadge } from './ActionBadge';
|
||||
11
frontend/src/components/analytics/index.ts
Normal file
11
frontend/src/components/analytics/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Analytics Components
|
||||
*
|
||||
* Reusable components for building consistent analytics pages
|
||||
*/
|
||||
|
||||
export { AnalyticsPageLayout } from './AnalyticsPageLayout';
|
||||
export { AnalyticsCard } from './AnalyticsCard';
|
||||
|
||||
export type { AnalyticsPageLayoutProps } from './AnalyticsPageLayout';
|
||||
export type { AnalyticsCardProps } from './AnalyticsCard';
|
||||
Reference in New Issue
Block a user