Improve the frontend 5
This commit is contained in:
115
frontend/src/api/hooks/auditLogs.ts
Normal file
115
frontend/src/api/hooks/auditLogs.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Audit Logs React Query hooks
|
||||
*
|
||||
* Provides React Query hooks for fetching and managing audit logs
|
||||
* across all microservices with caching and real-time updates.
|
||||
*
|
||||
* Last Updated: 2025-11-02
|
||||
* Status: ✅ Complete
|
||||
*/
|
||||
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { auditLogsService } from '../services/auditLogs';
|
||||
import {
|
||||
AuditLogResponse,
|
||||
AuditLogFilters,
|
||||
AuditLogListResponse,
|
||||
AuditLogStatsResponse,
|
||||
AggregatedAuditLog,
|
||||
AuditLogServiceName,
|
||||
} from '../types/auditLogs';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const auditLogKeys = {
|
||||
all: ['audit-logs'] as const,
|
||||
lists: () => [...auditLogKeys.all, 'list'] as const,
|
||||
list: (tenantId: string, filters?: AuditLogFilters) =>
|
||||
[...auditLogKeys.lists(), tenantId, filters] as const,
|
||||
serviceList: (tenantId: string, service: AuditLogServiceName, filters?: AuditLogFilters) =>
|
||||
[...auditLogKeys.lists(), 'service', tenantId, service, filters] as const,
|
||||
stats: () => [...auditLogKeys.all, 'stats'] as const,
|
||||
stat: (tenantId: string, filters?: { start_date?: string; end_date?: string }) =>
|
||||
[...auditLogKeys.stats(), tenantId, filters] as const,
|
||||
serviceStat: (
|
||||
tenantId: string,
|
||||
service: AuditLogServiceName,
|
||||
filters?: { start_date?: string; end_date?: string }
|
||||
) => [...auditLogKeys.stats(), 'service', tenantId, service, filters] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Hook to fetch audit logs from a single service
|
||||
*/
|
||||
export function useServiceAuditLogs(
|
||||
tenantId: string,
|
||||
serviceName: AuditLogServiceName,
|
||||
filters?: AuditLogFilters,
|
||||
options?: Omit<UseQueryOptions<AuditLogListResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) {
|
||||
return useQuery<AuditLogListResponse, ApiError>({
|
||||
queryKey: auditLogKeys.serviceList(tenantId, serviceName, filters),
|
||||
queryFn: () => auditLogsService.getServiceAuditLogs(tenantId, serviceName, filters),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch aggregated audit logs from ALL services
|
||||
*/
|
||||
export function useAllAuditLogs(
|
||||
tenantId: string,
|
||||
filters?: AuditLogFilters,
|
||||
options?: Omit<UseQueryOptions<AggregatedAuditLog[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) {
|
||||
return useQuery<AggregatedAuditLog[], ApiError>({
|
||||
queryKey: auditLogKeys.list(tenantId, filters),
|
||||
queryFn: () => auditLogsService.getAllAuditLogs(tenantId, filters),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch audit log statistics from a single service
|
||||
*/
|
||||
export function useServiceAuditLogStats(
|
||||
tenantId: string,
|
||||
serviceName: AuditLogServiceName,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
},
|
||||
options?: Omit<UseQueryOptions<AuditLogStatsResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) {
|
||||
return useQuery<AuditLogStatsResponse, ApiError>({
|
||||
queryKey: auditLogKeys.serviceStat(tenantId, serviceName, filters),
|
||||
queryFn: () => auditLogsService.getServiceAuditLogStats(tenantId, serviceName, filters),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 60000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch aggregated audit log statistics from ALL services
|
||||
*/
|
||||
export function useAllAuditLogStats(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
},
|
||||
options?: Omit<UseQueryOptions<AuditLogStatsResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) {
|
||||
return useQuery<AuditLogStatsResponse, ApiError>({
|
||||
queryKey: auditLogKeys.stat(tenantId, filters),
|
||||
queryFn: () => auditLogsService.getAllAuditLogStats(tenantId, filters),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 60000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
}
|
||||
267
frontend/src/api/services/auditLogs.ts
Normal file
267
frontend/src/api/services/auditLogs.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/auditLogs.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Audit Logs Aggregation Service
|
||||
*
|
||||
* Aggregates audit logs from all microservices and provides
|
||||
* unified access to system event history.
|
||||
*
|
||||
* Backend endpoints:
|
||||
* - GET /tenants/{tenant_id}/{service}/audit-logs
|
||||
* - GET /tenants/{tenant_id}/{service}/audit-logs/stats
|
||||
*
|
||||
* Last Updated: 2025-11-02
|
||||
* Status: ✅ Complete - Multi-service aggregation
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
AuditLogResponse,
|
||||
AuditLogFilters,
|
||||
AuditLogListResponse,
|
||||
AuditLogStatsResponse,
|
||||
AggregatedAuditLog,
|
||||
AUDIT_LOG_SERVICES,
|
||||
AuditLogServiceName,
|
||||
} from '../types/auditLogs';
|
||||
|
||||
export class AuditLogsService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
/**
|
||||
* Get audit logs from a single service
|
||||
*/
|
||||
async getServiceAuditLogs(
|
||||
tenantId: string,
|
||||
serviceName: AuditLogServiceName,
|
||||
filters?: AuditLogFilters
|
||||
): Promise<AuditLogListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
|
||||
if (filters?.user_id) queryParams.append('user_id', filters.user_id);
|
||||
if (filters?.action) queryParams.append('action', filters.action);
|
||||
if (filters?.resource_type) queryParams.append('resource_type', filters.resource_type);
|
||||
if (filters?.severity) queryParams.append('severity', filters.severity);
|
||||
if (filters?.search) queryParams.append('search', filters.search);
|
||||
if (filters?.limit) queryParams.append('limit', filters.limit.toString());
|
||||
if (filters?.offset) queryParams.append('offset', filters.offset.toString());
|
||||
|
||||
const url = `${this.baseUrl}/${tenantId}/${serviceName}/audit-logs${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
return apiClient.get<AuditLogListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log statistics from a single service
|
||||
*/
|
||||
async getServiceAuditLogStats(
|
||||
tenantId: string,
|
||||
serviceName: AuditLogServiceName,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<AuditLogStatsResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
|
||||
|
||||
const url = `${this.baseUrl}/${tenantId}/${serviceName}/audit-logs/stats${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
return apiClient.get<AuditLogStatsResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated audit logs from ALL services
|
||||
* Makes parallel requests to all services and combines results
|
||||
*/
|
||||
async getAllAuditLogs(
|
||||
tenantId: string,
|
||||
filters?: AuditLogFilters
|
||||
): Promise<AggregatedAuditLog[]> {
|
||||
// Make parallel requests to all services
|
||||
const promises = AUDIT_LOG_SERVICES.map(service =>
|
||||
this.getServiceAuditLogs(tenantId, service, {
|
||||
...filters,
|
||||
limit: filters?.limit || 100,
|
||||
}).catch(error => {
|
||||
// If a service fails, log the error but don't fail the entire request
|
||||
console.warn(`Failed to fetch audit logs from ${service}:`, error);
|
||||
return { items: [], total: 0, limit: 0, offset: 0, has_more: false };
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Combine all results
|
||||
const allLogs: AggregatedAuditLog[] = results.flatMap(result => result.items);
|
||||
|
||||
// Sort by created_at descending (most recent first)
|
||||
allLogs.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at).getTime();
|
||||
const dateB = new Date(b.created_at).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Apply limit if specified
|
||||
const limit = filters?.limit || 100;
|
||||
const offset = filters?.offset || 0;
|
||||
|
||||
return allLogs.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated statistics from ALL services
|
||||
*/
|
||||
async getAllAuditLogStats(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<AuditLogStatsResponse> {
|
||||
// Make parallel requests to all services
|
||||
const promises = AUDIT_LOG_SERVICES.map(service =>
|
||||
this.getServiceAuditLogStats(tenantId, service, filters).catch(error => {
|
||||
console.warn(`Failed to fetch audit log stats from ${service}:`, error);
|
||||
return {
|
||||
total_events: 0,
|
||||
events_by_action: {},
|
||||
events_by_severity: {},
|
||||
events_by_resource_type: {},
|
||||
date_range: { min: null, max: null },
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Aggregate statistics
|
||||
const aggregated: AuditLogStatsResponse = {
|
||||
total_events: 0,
|
||||
events_by_action: {},
|
||||
events_by_severity: {},
|
||||
events_by_resource_type: {},
|
||||
date_range: { min: null, max: null },
|
||||
};
|
||||
|
||||
for (const result of results) {
|
||||
aggregated.total_events += result.total_events;
|
||||
|
||||
// Merge events_by_action
|
||||
for (const [action, count] of Object.entries(result.events_by_action)) {
|
||||
aggregated.events_by_action[action] = (aggregated.events_by_action[action] || 0) + count;
|
||||
}
|
||||
|
||||
// Merge events_by_severity
|
||||
for (const [severity, count] of Object.entries(result.events_by_severity)) {
|
||||
aggregated.events_by_severity[severity] = (aggregated.events_by_severity[severity] || 0) + count;
|
||||
}
|
||||
|
||||
// Merge events_by_resource_type
|
||||
for (const [resource, count] of Object.entries(result.events_by_resource_type)) {
|
||||
aggregated.events_by_resource_type[resource] = (aggregated.events_by_resource_type[resource] || 0) + count;
|
||||
}
|
||||
|
||||
// Update date range
|
||||
if (result.date_range.min) {
|
||||
if (!aggregated.date_range.min || result.date_range.min < aggregated.date_range.min) {
|
||||
aggregated.date_range.min = result.date_range.min;
|
||||
}
|
||||
}
|
||||
if (result.date_range.max) {
|
||||
if (!aggregated.date_range.max || result.date_range.max > aggregated.date_range.max) {
|
||||
aggregated.date_range.max = result.date_range.max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit logs to CSV format
|
||||
*/
|
||||
exportToCSV(logs: AggregatedAuditLog[]): string {
|
||||
if (logs.length === 0) return '';
|
||||
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Service',
|
||||
'User ID',
|
||||
'Action',
|
||||
'Resource Type',
|
||||
'Resource ID',
|
||||
'Severity',
|
||||
'Description',
|
||||
'IP Address',
|
||||
'Endpoint',
|
||||
'Method',
|
||||
];
|
||||
|
||||
const rows = logs.map(log => [
|
||||
log.created_at,
|
||||
log.service_name,
|
||||
log.user_id || '',
|
||||
log.action,
|
||||
log.resource_type,
|
||||
log.resource_id || '',
|
||||
log.severity,
|
||||
log.description,
|
||||
log.ip_address || '',
|
||||
log.endpoint || '',
|
||||
log.method || '',
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(',')),
|
||||
].join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit logs to JSON format
|
||||
*/
|
||||
exportToJSON(logs: AggregatedAuditLog[]): string {
|
||||
return JSON.stringify(logs, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download audit logs as a file
|
||||
*/
|
||||
downloadAuditLogs(
|
||||
logs: AggregatedAuditLog[],
|
||||
format: 'csv' | 'json',
|
||||
filename?: string
|
||||
): void {
|
||||
const content = format === 'csv' ? this.exportToCSV(logs) : this.exportToJSON(logs);
|
||||
const blob = new Blob([content], {
|
||||
type: format === 'csv' ? 'text/csv;charset=utf-8;' : 'application/json',
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute(
|
||||
'download',
|
||||
filename || `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`
|
||||
);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const auditLogsService = new AuditLogsService();
|
||||
84
frontend/src/api/types/auditLogs.ts
Normal file
84
frontend/src/api/types/auditLogs.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/types/auditLogs.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Audit Log Types - TypeScript interfaces for audit log data
|
||||
*
|
||||
* Aligned with backend schema:
|
||||
* - shared/models/audit_log_schemas.py
|
||||
*
|
||||
* Last Updated: 2025-11-02
|
||||
* Status: ✅ Complete - Aligned with backend
|
||||
*/
|
||||
|
||||
export interface AuditLogResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
user_id: string | null;
|
||||
service_name: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string | null;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
description: string;
|
||||
changes: Record<string, any> | null;
|
||||
audit_metadata: Record<string, any> | null;
|
||||
endpoint: string | null;
|
||||
method: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
user_id?: string;
|
||||
action?: string;
|
||||
resource_type?: string;
|
||||
severity?: 'low' | 'medium' | 'high' | 'critical';
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogListResponse {
|
||||
items: AuditLogResponse[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface AuditLogStatsResponse {
|
||||
total_events: number;
|
||||
events_by_action: Record<string, number>;
|
||||
events_by_severity: Record<string, number>;
|
||||
events_by_resource_type: Record<string, number>;
|
||||
date_range: {
|
||||
min: string | null;
|
||||
max: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregated audit log (combines logs from all services)
|
||||
export interface AggregatedAuditLog extends AuditLogResponse {
|
||||
// All fields from AuditLogResponse, service_name distinguishes the source
|
||||
}
|
||||
|
||||
// Service list for audit log aggregation
|
||||
export const AUDIT_LOG_SERVICES = [
|
||||
'sales',
|
||||
'inventory',
|
||||
'orders',
|
||||
'production',
|
||||
'recipes',
|
||||
'suppliers',
|
||||
'pos',
|
||||
'training',
|
||||
'notification',
|
||||
'external',
|
||||
'forecasting',
|
||||
] as const;
|
||||
|
||||
export type AuditLogServiceName = typeof AUDIT_LOG_SERVICES[number];
|
||||
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';
|
||||
@@ -1,6 +1,108 @@
|
||||
{
|
||||
"title": "Event Log",
|
||||
"description": "Monitor system activity and important events",
|
||||
"title": "Event Registry",
|
||||
"description": "Tracking of all system activities and events",
|
||||
"showFilters": "Show Filters",
|
||||
"hideFilters": "Hide Filters",
|
||||
"clearFilters": "Clear Filters",
|
||||
"exportCSV": "Export CSV",
|
||||
"exportJSON": "Export JSON",
|
||||
"table": {
|
||||
"timestamp": "Timestamp",
|
||||
"service": "Service",
|
||||
"user": "User",
|
||||
"action": "Action",
|
||||
"resource": "Resource",
|
||||
"severity": "Severity",
|
||||
"description": "Description",
|
||||
"actions": "Actions",
|
||||
"view": "View"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"dateRange": "Date Range",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"severity": "Severity",
|
||||
"all": "All",
|
||||
"action": "Action",
|
||||
"resourceType": "Resource Type",
|
||||
"search": "Search in Description",
|
||||
"applyFilters": "Apply Filters",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"severity": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"critical": "Critical",
|
||||
"info": "Information",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
},
|
||||
"services": {
|
||||
"sales": "Sales",
|
||||
"inventory": "Inventory",
|
||||
"orders": "Orders",
|
||||
"production": "Production",
|
||||
"recipes": "Recipes",
|
||||
"suppliers": "Suppliers",
|
||||
"pos": "POS",
|
||||
"training": "Training",
|
||||
"notification": "Notifications",
|
||||
"external": "External",
|
||||
"forecasting": "Forecasting"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"view": "View",
|
||||
"sync": "Sync"
|
||||
},
|
||||
"stats": {
|
||||
"totalEvents": "Total Events",
|
||||
"criticalEvents": "Critical Events",
|
||||
"mostCommonAction": "Most Common Action",
|
||||
"period": "Period",
|
||||
"summary": "Summary"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Event Detail",
|
||||
"eventInfo": "Event Information",
|
||||
"changes": "Changes",
|
||||
"requestMetadata": "Request Metadata",
|
||||
"additionalMetadata": "Additional Metadata",
|
||||
"eventId": "Event ID",
|
||||
"endpoint": "Endpoint",
|
||||
"httpMethod": "HTTP Method",
|
||||
"ipAddress": "IP Address",
|
||||
"userAgent": "User Agent",
|
||||
"copyId": "Copy ID",
|
||||
"export": "Export Event",
|
||||
"close": "Close",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Showing",
|
||||
"to": "to",
|
||||
"of": "of",
|
||||
"events": "events",
|
||||
"page": "Page",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No events found",
|
||||
"message": "No audit logs match your current filters"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error loading events",
|
||||
"message": "An error occurred while fetching audit logs",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"categories": {
|
||||
"all": "All",
|
||||
"sales": "Sales",
|
||||
@@ -15,11 +117,5 @@
|
||||
"stock_updated": "Stock Updated",
|
||||
"customer_registered": "Customer Registered",
|
||||
"system_alert": "System Alert"
|
||||
},
|
||||
"severity": {
|
||||
"info": "Information",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +84,21 @@
|
||||
"trends": "Trends",
|
||||
"top_products": "Top products",
|
||||
"top_customers": "Top customers"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Customer Patterns",
|
||||
"hourly_traffic": "Hourly Traffic",
|
||||
"weekly_traffic": "Weekly Traffic",
|
||||
"peak_hours": "Peak Hours",
|
||||
"busiest_days": "Busiest Days",
|
||||
"hourly_description": "Transaction patterns by hour of day based on sales data",
|
||||
"weekly_description": "Distribution of transactions by day of the week",
|
||||
"no_hourly_data": "No hourly traffic data for this period",
|
||||
"no_weekly_data": "No weekly traffic data for this period",
|
||||
"no_peak_hours": "Not enough data to show peak hours",
|
||||
"no_busiest_days": "Not enough data to show busiest days",
|
||||
"transactions": "transactions",
|
||||
"peak_hour_label": "Peak hour",
|
||||
"active_day_label": "Active day"
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"title": "Traffic Analysis",
|
||||
"description": "Monitor customer flow and optimize service hours",
|
||||
"metrics": {
|
||||
"total_visitors": "Total Visitors",
|
||||
"peak_hour": "Peak Hour",
|
||||
"avg_duration": "Average Duration",
|
||||
"busy_days": "Busy Days",
|
||||
"conversion_rate": "Conversion Rate"
|
||||
},
|
||||
"periods": {
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"year": "Year"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"sources": {
|
||||
"walking": "Walk-in",
|
||||
"local_search": "Local Search",
|
||||
"recommendations": "Recommendations",
|
||||
"social_media": "Social Media",
|
||||
"advertising": "Advertising"
|
||||
},
|
||||
"segments": {
|
||||
"morning_regulars": "Morning Regulars",
|
||||
"weekend_families": "Weekend Families",
|
||||
"lunch_office": "Lunch Office Workers",
|
||||
"occasional_customers": "Occasional Customers"
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"title": "Weather Data",
|
||||
"description": "Integrate weather information to optimize production and sales",
|
||||
"current": {
|
||||
"title": "Current Conditions",
|
||||
"temperature": "Temperature",
|
||||
"humidity": "Humidity",
|
||||
"wind": "Wind",
|
||||
"pressure": "Pressure",
|
||||
"uv": "UV",
|
||||
"visibility": "Visibility",
|
||||
"favorable_conditions": "Favorable conditions"
|
||||
},
|
||||
"forecast": {
|
||||
"title": "Extended Forecast",
|
||||
"next_week": "Next Week",
|
||||
"next_month": "Next Month",
|
||||
"rain": "Rain"
|
||||
},
|
||||
"conditions": {
|
||||
"sunny": "Sunny",
|
||||
"partly_cloudy": "Partly cloudy",
|
||||
"cloudy": "Cloudy",
|
||||
"rainy": "Rainy"
|
||||
},
|
||||
"days": {
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Weather Impact",
|
||||
"high_demand": "High Demand",
|
||||
"comfort_food": "Comfort Food",
|
||||
"moderate": "Moderate Demand",
|
||||
"normal": "Normal Demand",
|
||||
"recommendations": "Recommendations"
|
||||
},
|
||||
"impacts": {
|
||||
"sunny_day": {
|
||||
"condition": "Sunny Day",
|
||||
"impact": "25% increase in cold drinks",
|
||||
"recommendations": [
|
||||
"Increase ice cream production",
|
||||
"More refreshing drinks",
|
||||
"Salads and fresh products",
|
||||
"Extended terrace hours"
|
||||
]
|
||||
},
|
||||
"rainy_day": {
|
||||
"condition": "Rainy Day",
|
||||
"impact": "40% increase in hot products",
|
||||
"recommendations": [
|
||||
"More soups and broths",
|
||||
"Hot chocolates",
|
||||
"Freshly baked bread",
|
||||
"Pastry products"
|
||||
]
|
||||
},
|
||||
"cold_day": {
|
||||
"condition": "Intense Cold",
|
||||
"impact": "Preference for comfort food",
|
||||
"recommendations": [
|
||||
"Increase baked goods",
|
||||
"Special hot drinks",
|
||||
"Energy products",
|
||||
"Indoor promotions"
|
||||
]
|
||||
}
|
||||
},
|
||||
"seasonal": {
|
||||
"title": "Seasonal Trends",
|
||||
"spring": {
|
||||
"name": "Spring",
|
||||
"period": "Mar - May",
|
||||
"avg_temp": "15-20°C",
|
||||
"trends": [
|
||||
"Increase in fresh products (+30%)",
|
||||
"Higher demand for salads",
|
||||
"Popular natural drinks",
|
||||
"Effective extended hours"
|
||||
]
|
||||
},
|
||||
"summer": {
|
||||
"name": "Summer",
|
||||
"period": "Jun - Aug",
|
||||
"avg_temp": "25-35°C",
|
||||
"trends": [
|
||||
"Peak of ice cream and slushies (+60%)",
|
||||
"Light products preferred",
|
||||
"Critical morning hours",
|
||||
"Higher tourist traffic"
|
||||
]
|
||||
},
|
||||
"autumn": {
|
||||
"name": "Autumn",
|
||||
"period": "Sep - Nov",
|
||||
"avg_temp": "10-18°C",
|
||||
"trends": [
|
||||
"Return to traditional products",
|
||||
"Increase in pastries (+20%)",
|
||||
"Popular hot drinks",
|
||||
"Regular schedules"
|
||||
]
|
||||
},
|
||||
"winter": {
|
||||
"name": "Winter",
|
||||
"period": "Dec - Feb",
|
||||
"avg_temp": "5-12°C",
|
||||
"trends": [
|
||||
"Maximum hot products (+50%)",
|
||||
"Critical freshly baked bread",
|
||||
"Festive chocolates and sweets",
|
||||
"Lower general traffic (-15%)"
|
||||
]
|
||||
},
|
||||
"impact_levels": {
|
||||
"high": "High",
|
||||
"positive": "Positive",
|
||||
"comfort": "Comfort",
|
||||
"stable": "Stable"
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Weather Alerts",
|
||||
"heat_wave": {
|
||||
"title": "Heat wave expected",
|
||||
"description": "Temperatures above 30°C expected for the next 3 days",
|
||||
"recommendation": "Increase stock of cold drinks and ice cream"
|
||||
},
|
||||
"heavy_rain": {
|
||||
"title": "Heavy rain on Monday",
|
||||
"description": "80% chance of precipitation with strong winds",
|
||||
"recommendation": "Prepare more hot products and shelter items"
|
||||
},
|
||||
"recommendation_label": "Recommendation"
|
||||
},
|
||||
"recommendations": {
|
||||
"increase_ice_cream": "Increase ice cream and cold drinks production",
|
||||
"standard_production": "Standard production",
|
||||
"comfort_foods": "Increase soups, hot chocolates and freshly baked bread",
|
||||
"indoor_focus": "Focus on indoor products",
|
||||
"fresh_products": "Increase fresh products and salads"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,108 @@
|
||||
{
|
||||
"title": "Registro de Eventos",
|
||||
"description": "Monitorea la actividad del sistema y eventos importantes",
|
||||
"description": "Seguimiento de todas las actividades y eventos del sistema",
|
||||
"showFilters": "Mostrar Filtros",
|
||||
"hideFilters": "Ocultar Filtros",
|
||||
"clearFilters": "Limpiar Filtros",
|
||||
"exportCSV": "Exportar CSV",
|
||||
"exportJSON": "Exportar JSON",
|
||||
"table": {
|
||||
"timestamp": "Fecha/Hora",
|
||||
"service": "Servicio",
|
||||
"user": "Usuario",
|
||||
"action": "Acción",
|
||||
"resource": "Recurso",
|
||||
"severity": "Severidad",
|
||||
"description": "Descripción",
|
||||
"actions": "Acciones",
|
||||
"view": "Ver"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros",
|
||||
"dateRange": "Rango de Fechas",
|
||||
"startDate": "Fecha de Inicio",
|
||||
"endDate": "Fecha de Fin",
|
||||
"severity": "Severidad",
|
||||
"all": "Todas",
|
||||
"action": "Acción",
|
||||
"resourceType": "Tipo de Recurso",
|
||||
"search": "Buscar en Descripción",
|
||||
"applyFilters": "Aplicar Filtros",
|
||||
"clear": "Limpiar"
|
||||
},
|
||||
"severity": {
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto",
|
||||
"critical": "Crítico",
|
||||
"info": "Información",
|
||||
"warning": "Advertencia",
|
||||
"error": "Error",
|
||||
"success": "Éxito"
|
||||
},
|
||||
"services": {
|
||||
"sales": "Ventas",
|
||||
"inventory": "Inventario",
|
||||
"orders": "Pedidos",
|
||||
"production": "Producción",
|
||||
"recipes": "Recetas",
|
||||
"suppliers": "Proveedores",
|
||||
"pos": "TPV",
|
||||
"training": "Entrenamiento",
|
||||
"notification": "Notificaciones",
|
||||
"external": "Externo",
|
||||
"forecasting": "Pronósticos"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Crear",
|
||||
"update": "Actualizar",
|
||||
"delete": "Eliminar",
|
||||
"approve": "Aprobar",
|
||||
"reject": "Rechazar",
|
||||
"view": "Ver",
|
||||
"sync": "Sincronizar"
|
||||
},
|
||||
"stats": {
|
||||
"totalEvents": "Total de Eventos",
|
||||
"criticalEvents": "Eventos Críticos",
|
||||
"mostCommonAction": "Acción Más Común",
|
||||
"period": "Período",
|
||||
"summary": "Resumen"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Detalle del Evento",
|
||||
"eventInfo": "Información del Evento",
|
||||
"changes": "Cambios",
|
||||
"requestMetadata": "Metadatos de Solicitud",
|
||||
"additionalMetadata": "Metadatos Adicionales",
|
||||
"eventId": "ID del Evento",
|
||||
"endpoint": "Endpoint",
|
||||
"httpMethod": "Método HTTP",
|
||||
"ipAddress": "Dirección IP",
|
||||
"userAgent": "User Agent",
|
||||
"copyId": "Copiar ID",
|
||||
"export": "Exportar Evento",
|
||||
"close": "Cerrar",
|
||||
"copy": "Copiar"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Mostrando",
|
||||
"to": "a",
|
||||
"of": "de",
|
||||
"events": "eventos",
|
||||
"page": "Página",
|
||||
"previous": "Anterior",
|
||||
"next": "Siguiente"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No se encontraron eventos",
|
||||
"message": "No hay registros de auditoría que coincidan con los filtros actuales"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error al cargar eventos",
|
||||
"message": "Ocurrió un error al obtener los registros de auditoría",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Todos",
|
||||
"sales": "Ventas",
|
||||
@@ -15,11 +117,5 @@
|
||||
"stock_updated": "Stock Actualizado",
|
||||
"customer_registered": "Cliente Registrado",
|
||||
"system_alert": "Alerta del Sistema"
|
||||
},
|
||||
"severity": {
|
||||
"info": "Información",
|
||||
"warning": "Advertencia",
|
||||
"error": "Error",
|
||||
"success": "Éxito"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +84,21 @@
|
||||
"trends": "Tendencias",
|
||||
"top_products": "Productos más vendidos",
|
||||
"top_customers": "Mejores clientes"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Patrones de Clientes",
|
||||
"hourly_traffic": "Tráfico por Hora",
|
||||
"weekly_traffic": "Tráfico Semanal",
|
||||
"peak_hours": "Horarios Pico",
|
||||
"busiest_days": "Días Más Activos",
|
||||
"hourly_description": "Patrones de transacciones por hora del día basados en datos de ventas",
|
||||
"weekly_description": "Distribución de transacciones por día de la semana",
|
||||
"no_hourly_data": "No hay datos de tráfico horario para este período",
|
||||
"no_weekly_data": "No hay datos de tráfico semanal para este período",
|
||||
"no_peak_hours": "No hay datos suficientes para mostrar horarios pico",
|
||||
"no_busiest_days": "No hay datos suficientes para mostrar días más activos",
|
||||
"transactions": "transacciones",
|
||||
"peak_hour_label": "Horario pico",
|
||||
"active_day_label": "Día activo"
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"title": "Análisis de Tráfico",
|
||||
"description": "Monitorea el flujo de clientes y optimiza las horas de atención",
|
||||
"metrics": {
|
||||
"total_visitors": "Visitantes Totales",
|
||||
"peak_hour": "Hora Pico",
|
||||
"avg_duration": "Duración Promedio",
|
||||
"busy_days": "Días Ocupados",
|
||||
"conversion_rate": "Tasa de Conversión"
|
||||
},
|
||||
"periods": {
|
||||
"week": "Semana",
|
||||
"month": "Mes",
|
||||
"year": "Año"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mié",
|
||||
"thu": "Jue",
|
||||
"fri": "Vie",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom"
|
||||
},
|
||||
"sources": {
|
||||
"walking": "Pie",
|
||||
"local_search": "Búsqueda Local",
|
||||
"recommendations": "Recomendaciones",
|
||||
"social_media": "Redes Sociales",
|
||||
"advertising": "Publicidad"
|
||||
},
|
||||
"segments": {
|
||||
"morning_regulars": "Regulares Matutinos",
|
||||
"weekend_families": "Familia Fin de Semana",
|
||||
"lunch_office": "Oficinistas Almuerzo",
|
||||
"occasional_customers": "Clientes Ocasionales"
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"title": "Datos Meteorológicos",
|
||||
"description": "Integra información del clima para optimizar la producción y ventas",
|
||||
"current": {
|
||||
"title": "Condiciones Actuales",
|
||||
"temperature": "Temperatura",
|
||||
"humidity": "Humedad",
|
||||
"wind": "Viento",
|
||||
"pressure": "Presión",
|
||||
"uv": "UV",
|
||||
"visibility": "Visibilidad",
|
||||
"favorable_conditions": "Condiciones favorables"
|
||||
},
|
||||
"forecast": {
|
||||
"title": "Pronóstico Extendido",
|
||||
"next_week": "Próxima Semana",
|
||||
"next_month": "Próximo Mes",
|
||||
"rain": "Lluvia"
|
||||
},
|
||||
"conditions": {
|
||||
"sunny": "Soleado",
|
||||
"partly_cloudy": "Parcialmente nublado",
|
||||
"cloudy": "Nublado",
|
||||
"rainy": "Lluvioso"
|
||||
},
|
||||
"days": {
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo",
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Impacto del Clima",
|
||||
"high_demand": "Alta Demanda",
|
||||
"comfort_food": "Comida Reconfortante",
|
||||
"moderate": "Demanda Moderada",
|
||||
"normal": "Demanda Normal",
|
||||
"recommendations": "Recomendaciones"
|
||||
},
|
||||
"impacts": {
|
||||
"sunny_day": {
|
||||
"condition": "Día Soleado",
|
||||
"impact": "Aumento del 25% en bebidas frías",
|
||||
"recommendations": [
|
||||
"Incrementar producción de helados",
|
||||
"Más bebidas refrescantes",
|
||||
"Ensaladas y productos frescos",
|
||||
"Horario extendido de terraza"
|
||||
]
|
||||
},
|
||||
"rainy_day": {
|
||||
"condition": "Día Lluvioso",
|
||||
"impact": "Aumento del 40% en productos calientes",
|
||||
"recommendations": [
|
||||
"Más sopas y caldos",
|
||||
"Chocolates calientes",
|
||||
"Pan recién horneado",
|
||||
"Productos de repostería"
|
||||
]
|
||||
},
|
||||
"cold_day": {
|
||||
"condition": "Frío Intenso",
|
||||
"impact": "Preferencia por comida reconfortante",
|
||||
"recommendations": [
|
||||
"Aumentar productos horneados",
|
||||
"Bebidas calientes especiales",
|
||||
"Productos energéticos",
|
||||
"Promociones de interior"
|
||||
]
|
||||
}
|
||||
},
|
||||
"seasonal": {
|
||||
"title": "Tendencias Estacionales",
|
||||
"spring": {
|
||||
"name": "Primavera",
|
||||
"period": "Mar - May",
|
||||
"avg_temp": "15-20°C",
|
||||
"trends": [
|
||||
"Aumento en productos frescos (+30%)",
|
||||
"Mayor demanda de ensaladas",
|
||||
"Bebidas naturales populares",
|
||||
"Horarios extendidos efectivos"
|
||||
]
|
||||
},
|
||||
"summer": {
|
||||
"name": "Verano",
|
||||
"period": "Jun - Ago",
|
||||
"avg_temp": "25-35°C",
|
||||
"trends": [
|
||||
"Pico de helados y granizados (+60%)",
|
||||
"Productos ligeros preferidos",
|
||||
"Horario matutino crítico",
|
||||
"Mayor tráfico de turistas"
|
||||
]
|
||||
},
|
||||
"autumn": {
|
||||
"name": "Otoño",
|
||||
"period": "Sep - Nov",
|
||||
"avg_temp": "10-18°C",
|
||||
"trends": [
|
||||
"Regreso a productos tradicionales",
|
||||
"Aumento en bollería (+20%)",
|
||||
"Bebidas calientes populares",
|
||||
"Horarios regulares"
|
||||
]
|
||||
},
|
||||
"winter": {
|
||||
"name": "Invierno",
|
||||
"period": "Dec - Feb",
|
||||
"avg_temp": "5-12°C",
|
||||
"trends": [
|
||||
"Máximo de productos calientes (+50%)",
|
||||
"Pan recién horneado crítico",
|
||||
"Chocolates y dulces festivos",
|
||||
"Menor tráfico general (-15%)"
|
||||
]
|
||||
},
|
||||
"impact_levels": {
|
||||
"high": "Alto",
|
||||
"positive": "Positivo",
|
||||
"comfort": "Confort",
|
||||
"stable": "Estable"
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Alertas Meteorológicas",
|
||||
"heat_wave": {
|
||||
"title": "Ola de calor prevista",
|
||||
"description": "Se esperan temperaturas superiores a 30°C los próximos 3 días",
|
||||
"recommendation": "Incrementar stock de bebidas frías y helados"
|
||||
},
|
||||
"heavy_rain": {
|
||||
"title": "Lluvia intensa el lunes",
|
||||
"description": "80% probabilidad de precipitación con vientos fuertes",
|
||||
"recommendation": "Preparar más productos calientes y de refugio"
|
||||
},
|
||||
"recommendation_label": "Recomendación"
|
||||
},
|
||||
"recommendations": {
|
||||
"increase_ice_cream": "Incrementar producción de helados y bebidas frías",
|
||||
"standard_production": "Producción estándar",
|
||||
"comfort_foods": "Aumentar sopas, chocolates calientes y pan recién horneado",
|
||||
"indoor_focus": "Enfoque en productos de interior",
|
||||
"fresh_products": "Incrementar productos frescos y ensaladas"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,108 @@
|
||||
{
|
||||
"title": "Gertaeren Erregistroa",
|
||||
"description": "Kontrolatu sistemaren jarduerak eta gertaera garrantzitsuak",
|
||||
"description": "Sistemaren jarduera eta gertaera guztien jarraipena",
|
||||
"showFilters": "Erakutsi Filtroak",
|
||||
"hideFilters": "Ezkutatu Filtroak",
|
||||
"clearFilters": "Garbitu Filtroak",
|
||||
"exportCSV": "Esportatu CSV",
|
||||
"exportJSON": "Esportatu JSON",
|
||||
"table": {
|
||||
"timestamp": "Data/Ordua",
|
||||
"service": "Zerbitzua",
|
||||
"user": "Erabiltzailea",
|
||||
"action": "Ekintza",
|
||||
"resource": "Baliabidea",
|
||||
"severity": "Larritasuna",
|
||||
"description": "Deskribapena",
|
||||
"actions": "Ekintzak",
|
||||
"view": "Ikusi"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtroak",
|
||||
"dateRange": "Data Tartea",
|
||||
"startDate": "Hasiera Data",
|
||||
"endDate": "Amaiera Data",
|
||||
"severity": "Larritasuna",
|
||||
"all": "Guztiak",
|
||||
"action": "Ekintza",
|
||||
"resourceType": "Baliabide Mota",
|
||||
"search": "Bilatu Deskribapen",
|
||||
"applyFilters": "Aplikatu Filtroak",
|
||||
"clear": "Garbitu"
|
||||
},
|
||||
"severity": {
|
||||
"low": "Baxua",
|
||||
"medium": "Ertaina",
|
||||
"high": "Altua",
|
||||
"critical": "Kritikoa",
|
||||
"info": "Informazioa",
|
||||
"warning": "Abisua",
|
||||
"error": "Errorea",
|
||||
"success": "Arrakasta"
|
||||
},
|
||||
"services": {
|
||||
"sales": "Salmentak",
|
||||
"inventory": "Inbentarioa",
|
||||
"orders": "Eskaerak",
|
||||
"production": "Ekoizpena",
|
||||
"recipes": "Errezetak",
|
||||
"suppliers": "Hornitzaileak",
|
||||
"pos": "TPV",
|
||||
"training": "Entrenamendua",
|
||||
"notification": "Jakinarazpenak",
|
||||
"external": "Kanpokoa",
|
||||
"forecasting": "Aurreikuspenak"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Sortu",
|
||||
"update": "Eguneratu",
|
||||
"delete": "Ezabatu",
|
||||
"approve": "Onartu",
|
||||
"reject": "Baztertu",
|
||||
"view": "Ikusi",
|
||||
"sync": "Sinkronizatu"
|
||||
},
|
||||
"stats": {
|
||||
"totalEvents": "Gertaera Guztiak",
|
||||
"criticalEvents": "Gertaera Kritikoak",
|
||||
"mostCommonAction": "Ekintza Ohikoena",
|
||||
"period": "Aldia",
|
||||
"summary": "Laburpena"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Gertaeraren Xehetasunak",
|
||||
"eventInfo": "Gertaeraren Informazioa",
|
||||
"changes": "Aldaketak",
|
||||
"requestMetadata": "Eskaeraren Metadatuak",
|
||||
"additionalMetadata": "Metadatu Gehigarriak",
|
||||
"eventId": "Gertaeraren ID",
|
||||
"endpoint": "Endpoint",
|
||||
"httpMethod": "HTTP Metodoa",
|
||||
"ipAddress": "IP Helbidea",
|
||||
"userAgent": "User Agent",
|
||||
"copyId": "Kopiatu ID",
|
||||
"export": "Esportatu Gertaera",
|
||||
"close": "Itxi",
|
||||
"copy": "Kopiatu"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Erakusten",
|
||||
"to": "-",
|
||||
"of": "guztira",
|
||||
"events": "gertaera",
|
||||
"page": "Orria",
|
||||
"previous": "Aurrekoa",
|
||||
"next": "Hurrengoa"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ez da gertaerarik aurkitu",
|
||||
"message": "Ez dago auditoria erregistrorik uneko filtroekin bat datozenak"
|
||||
},
|
||||
"error": {
|
||||
"title": "Errorea gertaerak kargatzean",
|
||||
"message": "Errore bat gertatu da auditoria erregistroak eskuratzean",
|
||||
"retry": "Saiatu berriro"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Denak",
|
||||
"sales": "Salmentak",
|
||||
@@ -15,11 +117,5 @@
|
||||
"stock_updated": "Stock Eguneratua",
|
||||
"customer_registered": "Bezero Erregistratua",
|
||||
"system_alert": "Sistemaren Alerta"
|
||||
},
|
||||
"severity": {
|
||||
"info": "Informazioa",
|
||||
"warning": "Abisua",
|
||||
"error": "Errorea",
|
||||
"success": "Arrakasta"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +84,21 @@
|
||||
"trends": "Joerak",
|
||||
"top_products": "Produktu onenak",
|
||||
"top_customers": "Bezero onenak"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Bezeroen Ereduak",
|
||||
"hourly_traffic": "Orduko Trafikoa",
|
||||
"weekly_traffic": "Asteko Trafikoa",
|
||||
"peak_hours": "Ordu Nagusiak",
|
||||
"busiest_days": "Egun Aktiboenak",
|
||||
"hourly_description": "Transakzioen ereduak eguneko orduz salmenten datuetan oinarrituta",
|
||||
"weekly_description": "Transakzioen banaketa asteko egunez",
|
||||
"no_hourly_data": "Ez dago orduko trafiko daturik aldialdi honetarako",
|
||||
"no_weekly_data": "Ez dago asteko trafiko daturik aldialdi honetarako",
|
||||
"no_peak_hours": "Ez dago datu nahikorik ordu nagusiak erakusteko",
|
||||
"no_busiest_days": "Ez dago datu nahikorik egun aktiboenak erakusteko",
|
||||
"transactions": "transakzioak",
|
||||
"peak_hour_label": "Ordu nagusia",
|
||||
"active_day_label": "Egun aktiboa"
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"title": "Trafiko Analisia",
|
||||
"description": "Kontrolatu bezeroen fluxua eta optimizatu zerbitzuaren ordutegia",
|
||||
"metrics": {
|
||||
"total_visitors": "Bisitari Guztiak",
|
||||
"peak_hour": "Gailur Ordua",
|
||||
"avg_duration": "Batezbesteko Iraupena",
|
||||
"busy_days": "Egun Okupatuak",
|
||||
"conversion_rate": "Bihurtze Tasa"
|
||||
},
|
||||
"periods": {
|
||||
"week": "Astea",
|
||||
"month": "Hilabetea",
|
||||
"year": "Urtea"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Astelehena",
|
||||
"tuesday": "Asteartea",
|
||||
"wednesday": "Asteazkena",
|
||||
"thursday": "Osteguna",
|
||||
"friday": "Ostirala",
|
||||
"saturday": "Larunbata",
|
||||
"sunday": "Igandea",
|
||||
"mon": "Asl",
|
||||
"tue": "Ast",
|
||||
"wed": "Azk",
|
||||
"thu": "Ost",
|
||||
"fri": "Orl",
|
||||
"sat": "Lar",
|
||||
"sun": "Ign"
|
||||
},
|
||||
"sources": {
|
||||
"walking": "Oinez",
|
||||
"local_search": "Tokiko Bilaketa",
|
||||
"recommendations": "Gomendioak",
|
||||
"social_media": "Sare Sozialak",
|
||||
"advertising": "Publizitatea"
|
||||
},
|
||||
"segments": {
|
||||
"morning_regulars": "Goizeko Erregularrak",
|
||||
"weekend_families": "Asteburu Familiak",
|
||||
"lunch_office": "Bazkari Bulegokideak",
|
||||
"occasional_customers": "Bezero Okazionalak"
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"title": "Eguraldiaren Datuak",
|
||||
"description": "Integratu eguraldiaren informazioa ekoizpena eta salmentak optimizatzeko",
|
||||
"current": {
|
||||
"title": "Uneko Baldintzak",
|
||||
"temperature": "Tenperatura",
|
||||
"humidity": "Hezetasuna",
|
||||
"wind": "Haizea",
|
||||
"pressure": "Presioa",
|
||||
"uv": "UV",
|
||||
"visibility": "Ikusgarritasuna",
|
||||
"favorable_conditions": "Baldintza onak"
|
||||
},
|
||||
"forecast": {
|
||||
"title": "Aurreikuspena Zabaldua",
|
||||
"next_week": "Hurrengo Astea",
|
||||
"next_month": "Hurrengo Hilabetea",
|
||||
"rain": "Euria"
|
||||
},
|
||||
"conditions": {
|
||||
"sunny": "Eguzkitsua",
|
||||
"partly_cloudy": "Partzialki lainotsua",
|
||||
"cloudy": "Lainotsua",
|
||||
"rainy": "Euriatsua"
|
||||
},
|
||||
"days": {
|
||||
"saturday": "Larunbata",
|
||||
"sunday": "Igandea",
|
||||
"monday": "Astelehena",
|
||||
"tuesday": "Asteartea",
|
||||
"wednesday": "Asteazkena",
|
||||
"thursday": "Osteguna",
|
||||
"friday": "Ostirala"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Eguraldiaren Eragina",
|
||||
"high_demand": "Eskari Handia",
|
||||
"comfort_food": "Erosotasun Janaria",
|
||||
"moderate": "Eskari Moderatua",
|
||||
"normal": "Eskari Normala",
|
||||
"recommendations": "Gomendioak"
|
||||
},
|
||||
"impacts": {
|
||||
"sunny_day": {
|
||||
"condition": "Eguzki Eguna",
|
||||
"impact": "%25eko igoera edari hotzetan",
|
||||
"recommendations": [
|
||||
"Handitu izozkien ekoizpena",
|
||||
"Edari freskagarri gehiago",
|
||||
"Entsaladak eta produktu freskoak",
|
||||
"Terrazako ordutegia luzatu"
|
||||
]
|
||||
},
|
||||
"rainy_day": {
|
||||
"condition": "Euri Eguna",
|
||||
"impact": "%20ko igoera produktu beroetan",
|
||||
"recommendations": [
|
||||
"Zopa eta saltsa gehiago",
|
||||
"Txokolate beroak",
|
||||
"Ogi freskoa",
|
||||
"Gozogintza produktuak"
|
||||
]
|
||||
},
|
||||
"cold_day": {
|
||||
"condition": "Hotz Sakona",
|
||||
"impact": "Erosotasun janarien lehentasuna",
|
||||
"recommendations": [
|
||||
"Handitu produktu labetuak",
|
||||
"Edari bero bereziak",
|
||||
"Energia produktuak",
|
||||
"Barruko promozioak"
|
||||
]
|
||||
}
|
||||
},
|
||||
"seasonal": {
|
||||
"title": "Sasoi Joerak",
|
||||
"spring": {
|
||||
"name": "Udaberria",
|
||||
"period": "Mar - Mai",
|
||||
"avg_temp": "15-20°C",
|
||||
"trends": [
|
||||
"Produktu freskoen igoera (+%30)",
|
||||
"Entsaladen eskari handiagoa",
|
||||
"Edari naturalak ezagunak",
|
||||
"Ordutegia luzatzea eraginkorra"
|
||||
]
|
||||
},
|
||||
"summer": {
|
||||
"name": "Uda",
|
||||
"period": "Eka - Abu",
|
||||
"avg_temp": "25-35°C",
|
||||
"trends": [
|
||||
"Izozkien eta granizatuen gailurra (+%60)",
|
||||
"Produktu arinak hobetsiak",
|
||||
"Goizeko ordutegia kritikoa",
|
||||
"Turista trafiko handiagoa"
|
||||
]
|
||||
},
|
||||
"autumn": {
|
||||
"name": "Udazkena",
|
||||
"period": "Ira - Aza",
|
||||
"avg_temp": "10-18°C",
|
||||
"trends": [
|
||||
"Produktu tradizionaletara itzulera",
|
||||
"Gozogintzan igoera (+%20)",
|
||||
"Edari bero ezagunak",
|
||||
"Ordutegia erregularra"
|
||||
]
|
||||
},
|
||||
"winter": {
|
||||
"name": "Negua",
|
||||
"period": "Abe - Ots",
|
||||
"avg_temp": "5-12°C",
|
||||
"trends": [
|
||||
"Produktu beroen maximoa (+%50)",
|
||||
"Ogi freskoa kritikoa",
|
||||
"Txokolate eta gozoki festiboak",
|
||||
"Trafiko orokorra txikiagoa (-%15)"
|
||||
]
|
||||
},
|
||||
"impact_levels": {
|
||||
"high": "Altua",
|
||||
"positive": "Positiboa",
|
||||
"comfort": "Erosotasuna",
|
||||
"stable": "Egonkorra"
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Eguraldi Alertak",
|
||||
"heat_wave": {
|
||||
"title": "Bero olatu aurreikusia",
|
||||
"description": "30°C baino tenperatura altuagoak espero dira hurrengo 3 egunetan",
|
||||
"recommendation": "Handitu edari hotz eta izozkien stocka"
|
||||
},
|
||||
"heavy_rain": {
|
||||
"title": "Euri sakona astelehenean",
|
||||
"description": "%80ko euritze probabilitatea haize indartsuarekin",
|
||||
"recommendation": "Prestatu produktu bero gehiago eta babeslekukoak"
|
||||
},
|
||||
"recommendation_label": "Gomendioa"
|
||||
},
|
||||
"recommendations": {
|
||||
"increase_ice_cream": "Handitu izozkien eta edari hotzen ekoizpena",
|
||||
"standard_production": "Ekoizpen estandarra",
|
||||
"comfort_foods": "Handitu zopak, txokolate beroak eta ogi freskoa",
|
||||
"indoor_focus": "Barruko produktuetan zentratu",
|
||||
"fresh_products": "Handitu produktu freskoak eta entsaladak"
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
Target,
|
||||
DollarSign,
|
||||
Award,
|
||||
Lock,
|
||||
BarChart3,
|
||||
Package,
|
||||
Truck,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
@@ -22,8 +20,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
import { PageHeader } from '../../../components/layout';
|
||||
import { Card, StatsGrid, Button, Tabs } from '../../../components/ui';
|
||||
import { AnalyticsPageLayout, AnalyticsCard } from '../../../components/analytics';
|
||||
import { useSubscription } from '../../../api/hooks/subscription';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProcurementDashboard, useProcurementTrends } from '../../../api/hooks/procurement';
|
||||
@@ -42,54 +39,6 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
// Check if user has access to advanced analytics (professional/enterprise)
|
||||
const hasAdvancedAccess = canAccessAnalytics('advanced');
|
||||
|
||||
// Show loading state while subscription data is being fetched
|
||||
if (subscriptionInfo.loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analítica de Compras"
|
||||
description="Insights avanzados sobre planificación de compras y gestión de proveedores"
|
||||
/>
|
||||
<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 (!hasAdvancedAccess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analítica de Compras"
|
||||
description="Insights avanzados sobre planificación de compras y gestión de proveedores"
|
||||
/>
|
||||
|
||||
<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">
|
||||
La analítica avanzada de compras está disponible solo para planes Professional y Enterprise.
|
||||
Actualiza tu plan para acceder a análisis detallados de proveedores, optimización de costos y métricas de rendimiento.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => window.location.hash = '#/app/settings/profile'}
|
||||
>
|
||||
Actualizar Plan
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{
|
||||
@@ -120,65 +69,50 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analítica de Compras"
|
||||
description="Insights avanzados sobre planificación de compras y gestión de proveedores"
|
||||
/>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: 'Planes Activos',
|
||||
value: dashboard?.summary?.total_plans || 0,
|
||||
icon: ShoppingCart,
|
||||
formatter: formatters.number
|
||||
},
|
||||
{
|
||||
label: 'Tasa de Cumplimiento',
|
||||
value: dashboard?.performance_metrics?.average_fulfillment_rate || 0,
|
||||
icon: Target,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.performance_metrics?.fulfillment_trend
|
||||
},
|
||||
{
|
||||
label: 'Entregas a Tiempo',
|
||||
value: dashboard?.performance_metrics?.average_on_time_delivery || 0,
|
||||
icon: Calendar,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.performance_metrics?.on_time_trend
|
||||
},
|
||||
{
|
||||
label: 'Variación de Costos',
|
||||
value: dashboard?.performance_metrics?.cost_accuracy || 0,
|
||||
icon: DollarSign,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.performance_metrics?.cost_variance_trend
|
||||
}
|
||||
]}
|
||||
loading={dashboardLoading}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
items={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
<AnalyticsPageLayout
|
||||
title="Analítica de Compras"
|
||||
description="Insights avanzados sobre planificación de compras y gestión de proveedores"
|
||||
subscriptionLoading={subscriptionInfo.loading}
|
||||
hasAccess={hasAdvancedAccess}
|
||||
dataLoading={dashboardLoading}
|
||||
stats={[
|
||||
{
|
||||
title: 'Planes Activos',
|
||||
value: dashboard?.summary?.total_plans || 0,
|
||||
icon: ShoppingCart,
|
||||
formatter: formatters.number
|
||||
},
|
||||
{
|
||||
title: 'Tasa de Cumplimiento',
|
||||
value: dashboard?.performance_metrics?.average_fulfillment_rate || 0,
|
||||
icon: Target,
|
||||
formatter: formatters.percentage
|
||||
},
|
||||
{
|
||||
title: 'Entregas a Tiempo',
|
||||
value: dashboard?.performance_metrics?.average_on_time_delivery || 0,
|
||||
icon: Calendar,
|
||||
formatter: formatters.percentage
|
||||
},
|
||||
{
|
||||
title: 'Variación de Costos',
|
||||
value: dashboard?.performance_metrics?.cost_accuracy || 0,
|
||||
icon: DollarSign,
|
||||
formatter: formatters.percentage
|
||||
}
|
||||
]}
|
||||
statsColumns={4}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
showMobileNotice={true}
|
||||
>
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Plan Status Distribution */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Distribución de Estados de Planes
|
||||
</h3>
|
||||
<AnalyticsCard title="Distribución de Estados de Planes">
|
||||
<div className="space-y-3">
|
||||
{dashboard?.plan_status_distribution?.map((status: any) => (
|
||||
<div key={status.status} className="flex items-center justify-between">
|
||||
@@ -197,18 +131,13 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
|
||||
{/* Critical Requirements */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">
|
||||
Requerimientos Críticos
|
||||
</h3>
|
||||
<AlertCircle className="h-5 w-5 text-[var(--color-error)]" />
|
||||
</div>
|
||||
<AnalyticsCard
|
||||
title="Requerimientos Críticos"
|
||||
actions={<AlertCircle className="h-5 w-5 text-[var(--color-error)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Stock Crítico</span>
|
||||
@@ -229,16 +158,11 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
</div>
|
||||
|
||||
{/* Recent Plans */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Planes Recientes
|
||||
</h3>
|
||||
<AnalyticsCard title="Planes Recientes">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
@@ -273,8 +197,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -282,50 +205,214 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<>
|
||||
{/* Performance Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<AnalyticsCard>
|
||||
<div className="text-center">
|
||||
<Target className="mx-auto h-8 w-8 text-[var(--color-success)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{formatters.percentage(dashboard?.performance_metrics?.average_fulfillment_rate || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Tasa de Cumplimiento</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<AnalyticsCard>
|
||||
<div className="text-center">
|
||||
<Calendar className="mx-auto h-8 w-8 text-[var(--color-info)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{formatters.percentage(dashboard?.performance_metrics?.average_on_time_delivery || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Entregas a Tiempo</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<AnalyticsCard>
|
||||
<div className="text-center">
|
||||
<Award className="mx-auto h-8 w-8 text-[var(--color-warning)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{dashboard?.performance_metrics?.supplier_performance?.toFixed(1) || '0.0'}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Puntuación de Calidad</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
</div>
|
||||
|
||||
{/* Performance Trend Chart */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Tendencias de Rendimiento (Últimos 7 días)
|
||||
</h3>
|
||||
{trendsLoading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<AnalyticsCard title="Tendencias de Rendimiento (Últimos 7 días)" loading={trendsLoading}>
|
||||
{trends && trends.performance_trend && trends.performance_trend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trends.performance_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
formatter={(value: any) => `${(value * 100).toFixed(1)}%`}
|
||||
labelStyle={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="fulfillment_rate"
|
||||
stroke="var(--color-success)"
|
||||
strokeWidth={2}
|
||||
name="Tasa de Cumplimiento"
|
||||
dot={{ fill: 'var(--color-success)' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="on_time_rate"
|
||||
stroke="var(--color-info)"
|
||||
strokeWidth={2}
|
||||
name="Entregas a Tiempo"
|
||||
dot={{ fill: 'var(--color-info)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
No hay datos de tendencias disponibles
|
||||
</div>
|
||||
)}
|
||||
</AnalyticsCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'suppliers' && (
|
||||
<>
|
||||
{/* Suppliers Tab */}
|
||||
<AnalyticsCard title="Rendimiento de Proveedores">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-primary)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Proveedor</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Órdenes</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Tasa Cumplimiento</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Entregas a Tiempo</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Calidad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard?.supplier_performance?.map((supplier: any) => (
|
||||
<tr key={supplier.id} className="border-b border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{supplier.name}</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">{supplier.total_orders}</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{formatters.percentage(supplier.fulfillment_rate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{formatters.percentage(supplier.on_time_rate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{supplier.quality_score?.toFixed(1) || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AnalyticsCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'costs' && (
|
||||
<>
|
||||
{/* Costs Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<AnalyticsCard title="Análisis de Costos">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Estimado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.summary?.total_estimated_cost || 0)}
|
||||
</span>
|
||||
</div>
|
||||
) : trends && trends.performance_trend && trends.performance_trend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trends.performance_trend}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Aprobado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.summary?.total_approved_cost || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Variación Promedio</span>
|
||||
<span className={`text-2xl font-bold ${
|
||||
(dashboard?.summary?.cost_variance || 0) > 0
|
||||
? 'text-[var(--color-error)]'
|
||||
: 'text-[var(--color-success)]'
|
||||
}`}>
|
||||
€{formatters.currency(Math.abs(dashboard?.summary?.cost_variance || 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsCard>
|
||||
|
||||
<AnalyticsCard title="Distribución de Costos por Categoría">
|
||||
<div className="space-y-3">
|
||||
{dashboard?.cost_by_category?.map((category: any) => (
|
||||
<div key={category.name} className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{category.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)]"
|
||||
style={{ width: `${(category.amount / (dashboard?.summary?.total_estimated_cost || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)] w-20 text-right">
|
||||
€{formatters.currency(category.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AnalyticsCard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<>
|
||||
{/* Quality Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<AnalyticsCard title="Métricas de Calidad">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Puntuación Promedio</span>
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{dashboard?.quality_metrics?.avg_score?.toFixed(1) || '0.0'} / 10
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Productos con Calidad Alta</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{dashboard?.quality_metrics?.high_quality_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Productos con Calidad Baja</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-error)]">
|
||||
{dashboard?.quality_metrics?.low_quality_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsCard>
|
||||
|
||||
<AnalyticsCard title="Tendencia de Calidad (Últimos 7 días)" loading={trendsLoading}>
|
||||
{trends && trends.quality_trend && trends.quality_trend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trends.quality_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -335,7 +422,8 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
|
||||
domain={[0, 10]}
|
||||
ticks={[0, 2, 4, 6, 8, 10]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
@@ -343,233 +431,29 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
formatter={(value: any) => `${(value * 100).toFixed(1)}%`}
|
||||
formatter={(value: any) => `${value.toFixed(1)} / 10`}
|
||||
labelStyle={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="fulfillment_rate"
|
||||
stroke="var(--color-success)"
|
||||
dataKey="quality_score"
|
||||
stroke="var(--color-warning)"
|
||||
strokeWidth={2}
|
||||
name="Tasa de Cumplimiento"
|
||||
dot={{ fill: 'var(--color-success)' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="on_time_rate"
|
||||
stroke="var(--color-info)"
|
||||
strokeWidth={2}
|
||||
name="Entregas a Tiempo"
|
||||
dot={{ fill: 'var(--color-info)' }}
|
||||
name="Puntuación de Calidad"
|
||||
dot={{ fill: 'var(--color-warning)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
No hay datos de tendencias disponibles
|
||||
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
No hay datos de calidad disponibles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'suppliers' && (
|
||||
<>
|
||||
{/* Suppliers Tab */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Rendimiento de Proveedores
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-primary)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Proveedor</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Órdenes</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Tasa Cumplimiento</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Entregas a Tiempo</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Calidad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard?.supplier_performance?.map((supplier: any) => (
|
||||
<tr key={supplier.id} className="border-b border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{supplier.name}</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">{supplier.total_orders}</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{formatters.percentage(supplier.fulfillment_rate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{formatters.percentage(supplier.on_time_rate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{supplier.quality_score?.toFixed(1) || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'costs' && (
|
||||
<>
|
||||
{/* Costs Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Análisis de Costos
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Estimado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.summary?.total_estimated_cost || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Aprobado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.summary?.total_approved_cost || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Variación Promedio</span>
|
||||
<span className={`text-2xl font-bold ${
|
||||
(dashboard?.summary?.cost_variance || 0) > 0
|
||||
? 'text-[var(--color-error)]'
|
||||
: 'text-[var(--color-success)]'
|
||||
}`}>
|
||||
€{formatters.currency(Math.abs(dashboard?.summary?.cost_variance || 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Distribución de Costos por Categoría
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{dashboard?.cost_by_category?.map((category: any) => (
|
||||
<div key={category.name} className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{category.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)]"
|
||||
style={{ width: `${(category.amount / (dashboard?.summary?.total_estimated_cost || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)] w-20 text-right">
|
||||
€{formatters.currency(category.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<>
|
||||
{/* Quality Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Métricas de Calidad
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Puntuación Promedio</span>
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{dashboard?.quality_metrics?.avg_score?.toFixed(1) || '0.0'} / 10
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Productos con Calidad Alta</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{dashboard?.quality_metrics?.high_quality_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Productos con Calidad Baja</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-error)]">
|
||||
{dashboard?.quality_metrics?.low_quality_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Tendencia de Calidad (Últimos 7 días)
|
||||
</h3>
|
||||
{trendsLoading ? (
|
||||
<div className="h-48 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
) : trends && trends.quality_trend && trends.quality_trend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trends.quality_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
domain={[0, 10]}
|
||||
ticks={[0, 2, 4, 6, 8, 10]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
formatter={(value: any) => `${value.toFixed(1)} / 10`}
|
||||
labelStyle={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="quality_score"
|
||||
stroke="var(--color-warning)"
|
||||
strokeWidth={2}
|
||||
name="Puntuación de Calidad"
|
||||
dot={{ fill: 'var(--color-warning)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
No hay datos de calidad disponibles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ import {
|
||||
Award,
|
||||
Settings,
|
||||
Brain,
|
||||
Lock,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Target,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../components/layout';
|
||||
import { Card, StatsGrid, Button, Tabs } from '../../../components/ui';
|
||||
import { AnalyticsPageLayout } from '../../../components/analytics';
|
||||
import { useSubscription } from '../../../api/hooks/subscription';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api/hooks/production';
|
||||
@@ -49,53 +47,6 @@ const ProductionAnalyticsPage: React.FC = () => {
|
||||
// Check if user has access to advanced analytics (professional/enterprise)
|
||||
const hasAdvancedAccess = canAccessAnalytics('advanced');
|
||||
|
||||
// Show loading state while subscription data is being fetched
|
||||
if (subscriptionInfo.loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('analytics.production_analytics')}
|
||||
description={t('analytics.advanced_insights_professionals_enterprises')}
|
||||
/>
|
||||
<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)]">{t('common.loading') || 'Cargando información de suscripción...'}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user doesn't have access to advanced analytics, show upgrade message
|
||||
if (!hasAdvancedAccess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('analytics.production_analytics')}
|
||||
description={t('analytics.advanced_insights_professionals_enterprises')}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{t('subscription.exclusive_professional_enterprise')}
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{t('subscription.advanced_production_analytics_description')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => window.location.hash = '#/app/settings/profile'}
|
||||
>
|
||||
{t('subscription.upgrade_plan')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{
|
||||
@@ -131,66 +82,67 @@ const ProductionAnalyticsPage: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('analytics.production_analytics')}
|
||||
description={t('analytics.advanced_insights_professionals_enterprises')}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
{t('actions.export_report')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{t('actions.optimize_production')}
|
||||
</Button>
|
||||
</div>
|
||||
<AnalyticsPageLayout
|
||||
title={t('analytics.production_analytics')}
|
||||
description={t('analytics.advanced_insights_professionals_enterprises')}
|
||||
subscriptionLoading={subscriptionInfo.loading}
|
||||
hasAccess={hasAdvancedAccess}
|
||||
dataLoading={dashboardLoading}
|
||||
actions={[
|
||||
{
|
||||
id: 'export-report',
|
||||
label: t('actions.export_report'),
|
||||
icon: TrendingUp,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
},
|
||||
{
|
||||
id: 'optimize-production',
|
||||
label: t('actions.optimize_production'),
|
||||
icon: Zap,
|
||||
onClick: () => {},
|
||||
variant: 'primary',
|
||||
size: 'sm',
|
||||
},
|
||||
]}
|
||||
stats={[
|
||||
{
|
||||
title: t('stats.overall_efficiency'),
|
||||
value: dashboard?.efficiency_percentage ? `${dashboard.efficiency_percentage.toFixed(1)}%` : '94%',
|
||||
variant: 'success' as const,
|
||||
icon: Target,
|
||||
subtitle: t('stats.vs_target_95')
|
||||
},
|
||||
{
|
||||
title: t('stats.average_cost_per_unit'),
|
||||
value: '€2.45',
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
subtitle: t('stats.down_3_vs_last_week')
|
||||
},
|
||||
{
|
||||
title: t('stats.active_equipment'),
|
||||
value: '8/9',
|
||||
variant: 'warning' as const,
|
||||
icon: Settings,
|
||||
subtitle: t('stats.one_in_maintenance')
|
||||
},
|
||||
{
|
||||
title: t('stats.quality_score'),
|
||||
value: dashboard?.average_quality_score ? `${dashboard.average_quality_score.toFixed(1)}/10` : '9.2/10',
|
||||
variant: 'success' as const,
|
||||
icon: Award,
|
||||
subtitle: t('stats.excellent_standards')
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Key Performance Indicators */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: t('stats.overall_efficiency'),
|
||||
value: dashboard?.efficiency_percentage ? `${dashboard.efficiency_percentage.toFixed(1)}%` : '94%',
|
||||
variant: 'success' as const,
|
||||
icon: Target,
|
||||
subtitle: t('stats.vs_target_95')
|
||||
},
|
||||
{
|
||||
title: t('stats.average_cost_per_unit'),
|
||||
value: '€2.45',
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
subtitle: t('stats.down_3_vs_last_week')
|
||||
},
|
||||
{
|
||||
title: t('stats.active_equipment'),
|
||||
value: '8/9',
|
||||
variant: 'warning' as const,
|
||||
icon: Settings,
|
||||
subtitle: t('stats.one_in_maintenance')
|
||||
},
|
||||
{
|
||||
title: t('stats.quality_score'),
|
||||
value: dashboard?.average_quality_score ? `${dashboard.average_quality_score.toFixed(1)}/10` : '9.2/10',
|
||||
variant: 'success' as const,
|
||||
icon: Award,
|
||||
subtitle: t('stats.excellent_standards')
|
||||
}
|
||||
]}
|
||||
columns={4}
|
||||
/>
|
||||
|
||||
{/* Analytics Tabs */}
|
||||
<Tabs
|
||||
items={tabs.map(tab => ({ id: tab.id, label: tab.label }))}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
]}
|
||||
statsColumns={4}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
showMobileNotice={true}
|
||||
mobileNoticeText={t('mobile.swipe_scroll_interact')}
|
||||
>
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-screen">
|
||||
{/* Overview Tab - Mixed Dashboard */}
|
||||
@@ -249,22 +201,7 @@ const ProductionAnalyticsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Optimization Notice */}
|
||||
<div className="md:hidden p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Target className="w-5 h-5 mt-0.5 text-blue-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('mobile.optimized_experience')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('mobile.swipe_scroll_interact')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Brain, TrendingUp, AlertTriangle, Lightbulb, Target, Zap, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { AnalyticsPageLayout, AnalyticsCard } from '../../../../components/analytics';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
@@ -156,67 +156,70 @@ const AIInsightsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Inteligencia Artificial"
|
||||
description="Insights inteligentes y recomendaciones automáticas para optimizar tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
<AnalyticsPageLayout
|
||||
title="Inteligencia Artificial"
|
||||
description="Insights inteligentes y recomendaciones automáticas para optimizar tu panadería"
|
||||
subscriptionLoading={false}
|
||||
hasAccess={true}
|
||||
dataLoading={isRefreshing}
|
||||
actions={[
|
||||
{
|
||||
id: 'refresh',
|
||||
label: 'Actualizar',
|
||||
icon: RefreshCw,
|
||||
onClick: handleRefresh,
|
||||
variant: 'outline',
|
||||
disabled: isRefreshing,
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
label: 'Exportar',
|
||||
icon: Download,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
},
|
||||
]}
|
||||
stats={[
|
||||
{
|
||||
title: "Total Insights",
|
||||
value: aiMetrics.totalInsights,
|
||||
icon: Brain,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Accionables",
|
||||
value: aiMetrics.actionableInsights,
|
||||
icon: Zap,
|
||||
variant: "success"
|
||||
},
|
||||
{
|
||||
title: "Confianza Promedio",
|
||||
value: `${aiMetrics.averageConfidence}%`,
|
||||
icon: Target,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Alta Prioridad",
|
||||
value: aiMetrics.highPriorityInsights,
|
||||
icon: AlertTriangle,
|
||||
variant: "error"
|
||||
},
|
||||
{
|
||||
title: "Media Prioridad",
|
||||
value: aiMetrics.mediumPriorityInsights,
|
||||
icon: TrendingUp,
|
||||
variant: "warning"
|
||||
},
|
||||
{
|
||||
title: "Baja Prioridad",
|
||||
value: aiMetrics.lowPriorityInsights,
|
||||
icon: Lightbulb,
|
||||
variant: "success"
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI Metrics */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: "Total Insights",
|
||||
value: aiMetrics.totalInsights,
|
||||
icon: Brain,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Accionables",
|
||||
value: aiMetrics.actionableInsights,
|
||||
icon: Zap,
|
||||
variant: "success"
|
||||
},
|
||||
{
|
||||
title: "Confianza Promedio",
|
||||
value: `${aiMetrics.averageConfidence}%`,
|
||||
icon: Target,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Alta Prioridad",
|
||||
value: aiMetrics.highPriorityInsights,
|
||||
icon: AlertTriangle,
|
||||
variant: "error"
|
||||
},
|
||||
{
|
||||
title: "Media Prioridad",
|
||||
value: aiMetrics.mediumPriorityInsights,
|
||||
icon: TrendingUp,
|
||||
variant: "warning"
|
||||
},
|
||||
{
|
||||
title: "Baja Prioridad",
|
||||
value: aiMetrics.lowPriorityInsights,
|
||||
icon: Lightbulb,
|
||||
variant: "success"
|
||||
}
|
||||
]}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
]}
|
||||
statsColumns={6}
|
||||
showMobileNotice={true}
|
||||
>
|
||||
{/* Category Filter */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -300,7 +303,7 @@ const AIInsightsPage: React.FC = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
357
frontend/src/pages/app/analytics/events/EventRegistryPage.tsx
Normal file
357
frontend/src/pages/app/analytics/events/EventRegistryPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Filter,
|
||||
Download,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
FileText,
|
||||
Activity,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { AnalyticsPageLayout } from '../../../../components/analytics';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { useAllAuditLogs, useAllAuditLogStats } from '../../../../api/hooks/auditLogs';
|
||||
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||
import { AuditLogFilters, AggregatedAuditLog } from '../../../../api/types/auditLogs';
|
||||
import { auditLogsService } from '../../../../api/services/auditLogs';
|
||||
import { EventFilterSidebar } from '../../../../components/analytics/events/EventFilterSidebar';
|
||||
import { EventDetailModal } from '../../../../components/analytics/events/EventDetailModal';
|
||||
import { EventStatsWidget } from '../../../../components/analytics/events/EventStatsWidget';
|
||||
import { SeverityBadge } from '../../../../components/analytics/events/SeverityBadge';
|
||||
import { ServiceBadge } from '../../../../components/analytics/events/ServiceBadge';
|
||||
import { ActionBadge } from '../../../../components/analytics/events/ActionBadge';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const EventRegistryPage: React.FC = () => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
// UI State
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const [selectedEvent, setSelectedEvent] = useState<AggregatedAuditLog | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
|
||||
// Filter State
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const paginatedFilters = useMemo(() => ({
|
||||
...filters,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
}), [filters, currentPage, pageSize]);
|
||||
|
||||
// Fetch audit logs
|
||||
const {
|
||||
data: auditLogs,
|
||||
isLoading: logsLoading,
|
||||
error: logsError,
|
||||
refetch: refetchLogs,
|
||||
} = useAllAuditLogs(tenantId, paginatedFilters, {
|
||||
enabled: !!tenantId,
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
// Fetch statistics
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: statsLoading,
|
||||
} = useAllAuditLogStats(
|
||||
tenantId,
|
||||
{
|
||||
start_date: filters.start_date,
|
||||
end_date: filters.end_date,
|
||||
},
|
||||
{
|
||||
enabled: !!tenantId,
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilters: Partial<AuditLogFilters>) => {
|
||||
setFilters(prev => ({ ...prev, ...newFilters }));
|
||||
setCurrentPage(1); // Reset to first page when filters change
|
||||
};
|
||||
|
||||
// Handle export
|
||||
const handleExport = async (format: 'csv' | 'json') => {
|
||||
if (!auditLogs || auditLogs.length === 0) return;
|
||||
|
||||
try {
|
||||
auditLogsService.downloadAuditLogs(auditLogs, format);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
// Get total pages
|
||||
const totalPages = Math.ceil((auditLogs?.length || 0) / pageSize);
|
||||
|
||||
return (
|
||||
<AnalyticsPageLayout
|
||||
title="Registro de Eventos"
|
||||
subtitle="Seguimiento de todas las actividades y eventos del sistema"
|
||||
icon={FileText}
|
||||
>
|
||||
{/* Statistics Widget */}
|
||||
{!statsLoading && stats && (
|
||||
<div className="mb-6">
|
||||
<EventStatsWidget stats={stats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={showFilters ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
icon={Filter}
|
||||
>
|
||||
{showFilters ? 'Ocultar Filtros' : 'Mostrar Filtros'}
|
||||
</Button>
|
||||
|
||||
{Object.keys(filters).length > 2 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFilters({ limit: 50, offset: 0 });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
icon={X}
|
||||
>
|
||||
Limpiar Filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleExport('csv')}
|
||||
icon={Download}
|
||||
disabled={!auditLogs || auditLogs.length === 0}
|
||||
>
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleExport('json')}
|
||||
icon={Download}
|
||||
disabled={!auditLogs || auditLogs.length === 0}
|
||||
>
|
||||
Exportar JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex gap-6">
|
||||
{/* Filter Sidebar */}
|
||||
{showFilters && (
|
||||
<div className="w-80 flex-shrink-0">
|
||||
<EventFilterSidebar
|
||||
filters={filters}
|
||||
onFiltersChange={handleFilterChange}
|
||||
stats={stats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Table */}
|
||||
<div className="flex-1">
|
||||
<Card>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : logsError ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<AlertTriangle className="mb-4 h-12 w-12 text-red-500" />
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
Error al cargar eventos
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Ocurrió un error al obtener los registros de auditoría
|
||||
</p>
|
||||
<Button onClick={() => refetchLogs()} size="sm">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : !auditLogs || auditLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Activity className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
No se encontraron eventos
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
No hay registros de auditoría que coincidan con los filtros actuales
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Servicio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acción
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Recurso
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Severidad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Descripción
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{auditLogs.map((event) => (
|
||||
<tr
|
||||
key={event.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(event.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<ServiceBadge service={event.service_name} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<ActionBadge action={event.action} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">
|
||||
{event.resource_type}
|
||||
</span>
|
||||
{event.resource_id && (
|
||||
<span className="text-xs text-gray-500 truncate max-w-xs" title={event.resource_id}>
|
||||
{event.resource_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<SeverityBadge severity={event.severity} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
<div className="max-w-md truncate" title={event.description}>
|
||||
{event.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
icon={Eye}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Mostrando{' '}
|
||||
<span className="font-medium">
|
||||
{(currentPage - 1) * pageSize + 1}
|
||||
</span>{' '}
|
||||
a{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, auditLogs.length)}
|
||||
</span>{' '}
|
||||
de{' '}
|
||||
<span className="font-medium">{auditLogs.length}</span>{' '}
|
||||
eventos
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Página {currentPage} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Detail Modal */}
|
||||
{selectedEvent && (
|
||||
<EventDetailModal
|
||||
event={selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventRegistryPage;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Settings, Loader, Zap, Brain, Target, Activity } from 'lucide-react';
|
||||
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { Button, Card, Badge, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { AnalyticsPageLayout, AnalyticsCard } from '../../../../components/analytics';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { DemandChart } from '../../../../components/domain/forecasting';
|
||||
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
||||
@@ -215,43 +215,41 @@ const ForecastingPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Predicción de Demanda"
|
||||
description="Sistema inteligente de predicción de demanda basado en IA"
|
||||
/>
|
||||
|
||||
{/* Stats Grid - Similar to POSPage */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: 'Ingredientes con Modelos',
|
||||
value: products.length,
|
||||
variant: 'default' as const,
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
title: 'Predicciones Generadas',
|
||||
value: forecasts.length,
|
||||
variant: 'info' as const,
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: 'Confianza Promedio',
|
||||
value: `${averageConfidence}%`,
|
||||
variant: 'success' as const,
|
||||
icon: Target,
|
||||
},
|
||||
{
|
||||
title: 'Demanda Total',
|
||||
value: formatters.number(Math.round(totalDemand)),
|
||||
variant: 'warning' as const,
|
||||
icon: BarChart3,
|
||||
},
|
||||
]}
|
||||
columns={4}
|
||||
/>
|
||||
|
||||
<AnalyticsPageLayout
|
||||
title="Predicción de Demanda"
|
||||
description="Sistema inteligente de predicción de demanda basado en IA"
|
||||
subscriptionLoading={false}
|
||||
hasAccess={true}
|
||||
dataLoading={isLoading}
|
||||
stats={[
|
||||
{
|
||||
title: 'Ingredientes con Modelos',
|
||||
value: products.length,
|
||||
variant: 'default' as const,
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
title: 'Predicciones Generadas',
|
||||
value: forecasts.length,
|
||||
variant: 'info' as const,
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: 'Confianza Promedio',
|
||||
value: `${averageConfidence}%`,
|
||||
variant: 'success' as const,
|
||||
icon: Target,
|
||||
},
|
||||
{
|
||||
title: 'Demanda Total',
|
||||
value: formatters.number(Math.round(totalDemand)),
|
||||
variant: 'warning' as const,
|
||||
icon: BarChart3,
|
||||
},
|
||||
]}
|
||||
statsColumns={4}
|
||||
showMobileNotice={true}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Ingredient Selection Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -485,7 +483,7 @@ const ForecastingPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
AlertCircle,
|
||||
Download,
|
||||
Calendar,
|
||||
Lock,
|
||||
BarChart3,
|
||||
Zap,
|
||||
DollarSign,
|
||||
@@ -31,8 +30,8 @@ import {
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
} from 'recharts';
|
||||
import { Button, Card, Badge, StatsGrid, Tabs } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { Badge, Card } from '../../../../components/ui';
|
||||
import { AnalyticsPageLayout, AnalyticsCard } from '../../../../components/analytics';
|
||||
import { useSubscription } from '../../../../api/hooks/subscription';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import {
|
||||
@@ -101,53 +100,6 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
departmentsLoading ||
|
||||
alertsLoading;
|
||||
|
||||
// Show loading state while subscription data is being fetched
|
||||
if (subscriptionInfo.loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
/>
|
||||
<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 (!canAccessAnalytics('advanced')) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
/>
|
||||
<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 de rendimiento avanzado está disponible solo para planes Professional y Enterprise.
|
||||
Actualiza tu plan para acceder a métricas transversales de rendimiento, análisis de procesos integrados y optimización operativa.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => (window.location.hash = '#/app/settings/profile')}
|
||||
>
|
||||
Actualizar Plan
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const getTrendIcon = (trend: 'up' | 'down' | 'stable') => {
|
||||
if (trend === 'up') {
|
||||
@@ -221,33 +173,31 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
actions={[
|
||||
{
|
||||
id: 'configure-alerts',
|
||||
label: 'Configurar Alertas',
|
||||
icon: Calendar,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'export-report',
|
||||
label: 'Exportar Reporte',
|
||||
icon: Download,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<AnalyticsPageLayout
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
subscriptionLoading={subscriptionInfo.loading}
|
||||
hasAccess={canAccessAnalytics('advanced')}
|
||||
dataLoading={isLoading}
|
||||
actions={[
|
||||
{
|
||||
id: 'configure-alerts',
|
||||
label: 'Configurar Alertas',
|
||||
icon: Calendar,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'export-report',
|
||||
label: 'Exportar Reporte',
|
||||
icon: Download,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
filters={
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
@@ -266,25 +216,20 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Block 1: StatsGrid with 6 cross-functional metrics */}
|
||||
<StatsGrid stats={statsData} loading={isLoading} />
|
||||
|
||||
{/* Block 2: Tabs */}
|
||||
<Tabs items={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Block 3: Tab Content */}
|
||||
<div className="space-y-6">
|
||||
}
|
||||
stats={statsData}
|
||||
statsColumns={6}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
showMobileNotice={true}
|
||||
>
|
||||
{/* Vista General Tab */}
|
||||
{activeTab === 'overview' && !isLoading && (
|
||||
<>
|
||||
{/* Department Comparison Matrix */}
|
||||
{departments && departments.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Comparación de Departamentos
|
||||
</h3>
|
||||
<AnalyticsCard title="Comparación de Departamentos">
|
||||
<div className="space-y-4">
|
||||
{departments.map((dept) => (
|
||||
<div key={dept.department_id} className="border rounded-lg p-4">
|
||||
@@ -331,15 +276,12 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
)}
|
||||
|
||||
{/* Process Efficiency Breakdown */}
|
||||
{processScore && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Desglose de Eficiencia por Procesos
|
||||
</h3>
|
||||
<AnalyticsCard title="Desglose de Eficiencia por Procesos">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={[
|
||||
@@ -358,7 +300,7 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
<Bar dataKey="weight" fill="var(--color-secondary)" name="Peso (%)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</AnalyticsCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -641,8 +583,7 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Calendar, TrendingUp, Euro, ShoppingCart, Download, Filter, Eye, Users, Package, CreditCard, BarChart3, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { Button, Card, Badge, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { AnalyticsPageLayout, AnalyticsCard } from '../../../../components/analytics';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { useSalesAnalytics, useSalesRecords, useProductCategories } from '../../../../api/hooks/sales';
|
||||
@@ -11,7 +11,7 @@ import { SalesDataResponse } from '../../../../api/types/sales';
|
||||
const SalesAnalyticsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('year');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview');
|
||||
const [viewMode, setViewMode] = useState<'overview' | 'detailed' | 'patterns'>('overview');
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
@@ -139,6 +139,50 @@ const SalesAnalyticsPage: React.FC = () => {
|
||||
}));
|
||||
}, [salesRecords]);
|
||||
|
||||
// Process traffic patterns from sales data
|
||||
const trafficPatterns = useMemo(() => {
|
||||
if (!salesRecords || salesRecords.length === 0) {
|
||||
return {
|
||||
hourlyTraffic: [],
|
||||
weeklyTraffic: []
|
||||
};
|
||||
}
|
||||
|
||||
// Hourly traffic: count transactions per hour
|
||||
const hourlyMap = new Map<number, number>();
|
||||
const weeklyMap = new Map<number, number>();
|
||||
|
||||
salesRecords.forEach(record => {
|
||||
const date = new Date(record.date);
|
||||
const hour = date.getHours();
|
||||
const dayOfWeek = date.getDay(); // 0 = Sunday, 6 = Saturday
|
||||
|
||||
// Count transactions per hour
|
||||
hourlyMap.set(hour, (hourlyMap.get(hour) || 0) + 1);
|
||||
|
||||
// Count transactions per day of week
|
||||
weeklyMap.set(dayOfWeek, (weeklyMap.get(dayOfWeek) || 0) + 1);
|
||||
});
|
||||
|
||||
// Format hourly traffic data (0-23 hours)
|
||||
const hourlyTraffic = Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour: `${hour.toString().padStart(2, '0')}:00`,
|
||||
transactions: hourlyMap.get(hour) || 0
|
||||
}));
|
||||
|
||||
// Format weekly traffic data (Sun-Sat)
|
||||
const dayNames = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
|
||||
const weeklyTraffic = dayNames.map((day, index) => ({
|
||||
day,
|
||||
transactions: weeklyMap.get(index) || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
hourlyTraffic,
|
||||
weeklyTraffic
|
||||
};
|
||||
}, [salesRecords]);
|
||||
|
||||
// Categories for filter
|
||||
const categories = useMemo(() => {
|
||||
const allCategories = [{ value: 'all', label: 'Todas las Categorías' }];
|
||||
@@ -349,25 +393,23 @@ const SalesAnalyticsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Ventas"
|
||||
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export-data",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => handleExport('csv'),
|
||||
tooltip: "Exportar datos a CSV",
|
||||
disabled: exportLoading || !salesRecords?.length
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-4">
|
||||
<AnalyticsPageLayout
|
||||
title="Análisis de Ventas"
|
||||
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
|
||||
subscriptionLoading={false}
|
||||
hasAccess={true}
|
||||
dataLoading={isLoading}
|
||||
actions={[
|
||||
{
|
||||
id: "export-data",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => handleExport('csv'),
|
||||
disabled: exportLoading || !salesRecords?.length
|
||||
}
|
||||
]}
|
||||
filters={
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
@@ -408,6 +450,15 @@ const SalesAnalyticsPage: React.FC = () => {
|
||||
<BarChart3 className="w-4 h-4 mr-1" />
|
||||
General
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'patterns' ? 'primary' : 'outline'}
|
||||
onClick={() => setViewMode('patterns')}
|
||||
size="sm"
|
||||
className="rounded-none flex-1 border-l-0"
|
||||
>
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
Patrones
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'primary' : 'outline'}
|
||||
onClick={() => setViewMode('detailed')}
|
||||
@@ -421,32 +472,29 @@ const SalesAnalyticsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: 'Ingresos Totales',
|
||||
value: formatters.currency(salesMetrics.totalRevenue),
|
||||
variant: 'success' as const,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Total Transacciones',
|
||||
value: salesMetrics.totalOrders.toLocaleString(),
|
||||
variant: 'info' as const,
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: 'Ticket Promedio',
|
||||
value: formatters.currency(salesMetrics.averageOrderValue),
|
||||
variant: 'warning' as const,
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: 'Cantidad Total',
|
||||
value: salesMetrics.totalQuantity.toLocaleString(),
|
||||
}
|
||||
stats={[
|
||||
{
|
||||
title: 'Ingresos Totales',
|
||||
value: formatters.currency(salesMetrics.totalRevenue),
|
||||
variant: 'success' as const,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Total Transacciones',
|
||||
value: salesMetrics.totalOrders.toLocaleString(),
|
||||
variant: 'info' as const,
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: 'Ticket Promedio',
|
||||
value: formatters.currency(salesMetrics.averageOrderValue),
|
||||
variant: 'warning' as const,
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: 'Cantidad Total',
|
||||
value: salesMetrics.totalQuantity.toLocaleString(),
|
||||
variant: 'default' as const,
|
||||
icon: Package,
|
||||
},
|
||||
@@ -463,10 +511,185 @@ const SalesAnalyticsPage: React.FC = () => {
|
||||
icon: Users,
|
||||
},
|
||||
]}
|
||||
columns={3}
|
||||
/>
|
||||
statsColumns={6}
|
||||
showMobileNotice={true}
|
||||
>
|
||||
{viewMode === 'patterns' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Hourly Traffic Pattern */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
Tráfico por Hora
|
||||
</h3>
|
||||
{trafficPatterns.hourlyTraffic.length === 0 || trafficPatterns.hourlyTraffic.every(h => h.transactions === 0) ? (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">No hay datos de tráfico horario para este período</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{trafficPatterns.hourlyTraffic.map((data, index) => {
|
||||
const maxTransactions = Math.max(...trafficPatterns.hourlyTraffic.map(h => h.transactions));
|
||||
const height = maxTransactions > 0 ? (data.transactions / maxTransactions) * 200 : 4;
|
||||
|
||||
{viewMode === 'overview' ? (
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center flex-1 group relative">
|
||||
{data.transactions > 0 && (
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.transactions}</div>
|
||||
)}
|
||||
<div
|
||||
className="w-full bg-[var(--color-info)]/70 hover:bg-[var(--color-info)] rounded-t transition-colors cursor-pointer"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
title={`${data.hour}: ${data.transactions} transacciones`}
|
||||
></div>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center whitespace-nowrap">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-secondary)]">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Patrones de transacciones por hora del día basados en datos de ventas
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weekly Traffic Pattern */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
Tráfico Semanal
|
||||
</h3>
|
||||
{trafficPatterns.weeklyTraffic.length === 0 || trafficPatterns.weeklyTraffic.every(d => d.transactions === 0) ? (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">No hay datos de tráfico semanal para este período</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex items-end space-x-2 justify-between">
|
||||
{trafficPatterns.weeklyTraffic.map((data, index) => {
|
||||
const maxTransactions = Math.max(...trafficPatterns.weeklyTraffic.map(d => d.transactions));
|
||||
const height = maxTransactions > 0 ? (data.transactions / maxTransactions) * 200 : 8;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.transactions}</div>
|
||||
<div
|
||||
className="w-full bg-[var(--color-success)] hover:bg-[var(--color-success)]/80 rounded-t transition-colors cursor-pointer"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
minHeight: '8px'
|
||||
}}
|
||||
title={`${data.day}: ${data.transactions} transacciones`}
|
||||
></div>
|
||||
<span className="text-sm text-[var(--text-secondary)] mt-2 font-medium">
|
||||
{data.day}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-secondary)]">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Distribución de transacciones por día de la semana
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Peak Hours Summary */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
|
||||
<TrendingUp className="w-5 h-5 mr-2" />
|
||||
Resumen de Horarios Pico
|
||||
</h3>
|
||||
{trafficPatterns.hourlyTraffic.length > 0 && trafficPatterns.hourlyTraffic.some(h => h.transactions > 0) ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const sorted = [...trafficPatterns.hourlyTraffic]
|
||||
.filter(h => h.transactions > 0)
|
||||
.sort((a, b) => b.transactions - a.transactions)
|
||||
.slice(0, 5);
|
||||
|
||||
return sorted.map((data, index) => (
|
||||
<div key={data.hour} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-[var(--text-tertiary)] w-6">{index + 1}.</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{data.hour}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Horario pico</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--color-info)]">
|
||||
{data.transactions} transacciones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<TrendingUp className="mx-auto h-8 w-8 text-[var(--text-tertiary)] mb-2" />
|
||||
<p className="text-[var(--text-secondary)]">No hay datos suficientes para mostrar horarios pico</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Busiest Days Summary */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
Días Más Activos
|
||||
</h3>
|
||||
{trafficPatterns.weeklyTraffic.length > 0 && trafficPatterns.weeklyTraffic.some(d => d.transactions > 0) ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const sorted = [...trafficPatterns.weeklyTraffic]
|
||||
.filter(d => d.transactions > 0)
|
||||
.sort((a, b) => b.transactions - a.transactions)
|
||||
.slice(0, 5);
|
||||
|
||||
return sorted.map((data, index) => (
|
||||
<div key={data.day} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
index === 0 ? 'bg-[var(--color-success)]' :
|
||||
index === 1 ? 'bg-[var(--color-info)]' :
|
||||
index === 2 ? 'bg-[var(--color-warning)]' :
|
||||
'bg-[var(--color-primary)]'
|
||||
}`}></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{data.day}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Día activo</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--color-success)]">
|
||||
{data.transactions} transacciones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="mx-auto h-8 w-8 text-[var(--text-tertiary)] mb-2" />
|
||||
<p className="text-[var(--text-secondary)]">No hay datos suficientes para mostrar días más activos</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : viewMode === 'overview' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
<Card className="p-6">
|
||||
@@ -740,7 +963,7 @@ const SalesAnalyticsPage: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Button,
|
||||
Badge,
|
||||
} from '../../../../components/ui';
|
||||
import { AnalyticsPageLayout } from '../../../../components/analytics';
|
||||
import {
|
||||
CloudRain,
|
||||
Sun,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
Sparkles,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { useModels } from '../../../../api/hooks/training';
|
||||
|
||||
@@ -220,17 +220,14 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('analytics.scenario_simulation.title', 'Scenario Simulation')}
|
||||
subtitle={t('analytics.scenario_simulation.subtitle', 'Test "what-if" scenarios to optimize your planning')}
|
||||
icon={Sparkles}
|
||||
status={{
|
||||
text: t('subscription.professional_enterprise', 'Professional/Enterprise'),
|
||||
variant: 'primary'
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnalyticsPageLayout
|
||||
title={t('analytics.scenario_simulation.title', 'Scenario Simulation')}
|
||||
description={t('analytics.scenario_simulation.subtitle', 'Test "what-if" scenarios to optimize your planning')}
|
||||
subscriptionLoading={false}
|
||||
hasAccess={true}
|
||||
dataLoading={isSimulating}
|
||||
showMobileNotice={true}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
@@ -1061,7 +1058,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Activity, Filter, Download, Eye, BarChart3 } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const EventsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
|
||||
const events = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
category: 'sales',
|
||||
type: 'order_completed',
|
||||
title: 'Pedido Completado',
|
||||
description: 'Pedido #ORD-456 completado por €127.50',
|
||||
metadata: {
|
||||
orderId: 'ORD-456',
|
||||
amount: 127.50,
|
||||
customer: 'María González',
|
||||
items: 8
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
category: 'production',
|
||||
type: 'batch_started',
|
||||
title: 'Lote Iniciado',
|
||||
description: 'Iniciado lote de croissants CR-024',
|
||||
metadata: {
|
||||
batchId: 'CR-024',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
expectedDuration: '2.5h'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
category: 'inventory',
|
||||
type: 'stock_updated',
|
||||
title: 'Stock Actualizado',
|
||||
description: 'Repuesto stock de harina - Nivel: 50kg',
|
||||
metadata: {
|
||||
item: 'Harina de Trigo',
|
||||
previousLevel: '5kg',
|
||||
newLevel: '50kg',
|
||||
supplier: 'Molinos del Sur'
|
||||
},
|
||||
severity: 'success'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
category: 'system',
|
||||
type: 'user_login',
|
||||
title: 'Inicio de Sesión',
|
||||
description: 'Usuario admin ha iniciado sesión',
|
||||
metadata: {
|
||||
userId: 'admin',
|
||||
ipAddress: '192.168.1.100',
|
||||
userAgent: 'Chrome/120.0',
|
||||
location: 'Madrid, ES'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: '2024-01-25 19:20:00',
|
||||
category: 'sales',
|
||||
type: 'payment_processed',
|
||||
title: 'Pago Procesado',
|
||||
description: 'Pago de €45.80 procesado exitosamente',
|
||||
metadata: {
|
||||
amount: 45.80,
|
||||
method: 'Tarjeta',
|
||||
reference: 'PAY-789',
|
||||
customer: 'Juan Pérez'
|
||||
},
|
||||
severity: 'success'
|
||||
}
|
||||
];
|
||||
|
||||
const eventStats = {
|
||||
total: events.length,
|
||||
today: events.filter(e =>
|
||||
new Date(e.timestamp).toDateString() === new Date().toDateString()
|
||||
).length,
|
||||
sales: events.filter(e => e.category === 'sales').length,
|
||||
production: events.filter(e => e.category === 'production').length,
|
||||
system: events.filter(e => e.category === 'system').length
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todos', count: events.length },
|
||||
{ value: 'sales', label: 'Ventas', count: eventStats.sales },
|
||||
{ value: 'production', label: 'Producción', count: eventStats.production },
|
||||
{ value: 'inventory', label: 'Inventario', count: events.filter(e => e.category === 'inventory').length },
|
||||
{ value: 'system', label: 'Sistema', count: eventStats.system }
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'success': return 'green';
|
||||
case 'warning': return 'yellow';
|
||||
case 'error': return 'red';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
switch (category) {
|
||||
case 'sales': return <BarChart3 {...iconProps} />;
|
||||
case 'production': return <Activity {...iconProps} />;
|
||||
case 'inventory': return <Calendar {...iconProps} />;
|
||||
default: return <Activity {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = selectedCategory === 'all'
|
||||
? events
|
||||
: events.filter(event => event.category === selectedCategory);
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const eventTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - eventTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInDays > 0) {
|
||||
return `hace ${diffInDays}d`;
|
||||
} else if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${Math.floor(diffInMs / (1000 * 60))}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title={t('events:title', 'Registro de Eventos')}
|
||||
description={t('events:description', 'Seguimiento de todas las actividades y eventos del sistema')}
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros Avanzados
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Event Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Eventos</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{eventStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Hoy</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{eventStats.today}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{eventStats.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Producción</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{eventStats.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="all">Todos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEvents.map((event) => (
|
||||
<Card key={event.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className={`p-2 rounded-lg bg-${getSeverityColor(event.severity)}-100`}>
|
||||
{getCategoryIcon(event.category)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{event.title}</h3>
|
||||
<Badge variant={getSeverityColor(event.severity)}>
|
||||
{event.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--text-secondary)] mb-3">{event.description}</p>
|
||||
|
||||
{/* Event Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<div key={key} className="bg-[var(--bg-secondary)] p-3 rounded-lg">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wider mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-[var(--text-tertiary)]">
|
||||
<span>{formatTimeAgo(event.timestamp)}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{new Date(event.timestamp).toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Activity className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No hay eventos</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
No se encontraron eventos para el período y categoría seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventsPage;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as EventsPage } from './EventsPage';
|
||||
@@ -1,338 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Users, Clock, TrendingUp, MapPin, Calendar, BarChart3, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TrafficPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedMetric, setSelectedMetric] = useState('visitors');
|
||||
|
||||
const trafficData = {
|
||||
totalVisitors: 2847,
|
||||
peakHour: '12:00',
|
||||
averageVisitDuration: '23min',
|
||||
busyDays: ['Viernes', 'Sábado'],
|
||||
conversionRate: 68.4
|
||||
};
|
||||
|
||||
const hourlyTraffic = [
|
||||
{ hour: '07:00', visitors: 15, sales: 12, duration: '18min' },
|
||||
{ hour: '08:00', visitors: 32, sales: 24, duration: '22min' },
|
||||
{ hour: '09:00', visitors: 45, sales: 28, duration: '25min' },
|
||||
{ hour: '10:00', visitors: 38, sales: 25, duration: '24min' },
|
||||
{ hour: '11:00', visitors: 52, sales: 35, duration: '26min' },
|
||||
{ hour: '12:00', visitors: 78, sales: 54, duration: '28min' },
|
||||
{ hour: '13:00', visitors: 85, sales: 58, duration: '30min' },
|
||||
{ hour: '14:00', visitors: 62, sales: 42, duration: '27min' },
|
||||
{ hour: '15:00', visitors: 48, sales: 32, duration: '25min' },
|
||||
{ hour: '16:00', visitors: 55, sales: 38, duration: '26min' },
|
||||
{ hour: '17:00', visitors: 68, sales: 46, duration: '29min' },
|
||||
{ hour: '18:00', visitors: 74, sales: 52, duration: '31min' },
|
||||
{ hour: '19:00', visitors: 56, sales: 39, duration: '28min' },
|
||||
{ hour: '20:00', visitors: 28, sales: 18, duration: '22min' }
|
||||
];
|
||||
|
||||
const dailyTraffic = [
|
||||
{ day: 'Lun', visitors: 245, sales: 168, conversion: 68.6, avgDuration: '22min' },
|
||||
{ day: 'Mar', visitors: 289, sales: 195, conversion: 67.5, avgDuration: '24min' },
|
||||
{ day: 'Mié', visitors: 321, sales: 218, conversion: 67.9, avgDuration: '25min' },
|
||||
{ day: 'Jue', visitors: 356, sales: 242, conversion: 68.0, avgDuration: '26min' },
|
||||
{ day: 'Vie', visitors: 445, sales: 312, conversion: 70.1, avgDuration: '28min' },
|
||||
{ day: 'Sáb', visitors: 498, sales: 348, conversion: 69.9, avgDuration: '30min' },
|
||||
{ day: 'Dom', visitors: 398, sales: 265, conversion: 66.6, avgDuration: '27min' }
|
||||
];
|
||||
|
||||
const trafficSources = [
|
||||
{ source: 'Pie', visitors: 1245, percentage: 43.7, trend: 5.2 },
|
||||
{ source: 'Búsqueda Local', visitors: 687, percentage: 24.1, trend: 12.3 },
|
||||
{ source: 'Recomendaciones', visitors: 423, percentage: 14.9, trend: -2.1 },
|
||||
{ source: 'Redes Sociales', visitors: 298, percentage: 10.5, trend: 8.7 },
|
||||
{ source: 'Publicidad', visitors: 194, percentage: 6.8, trend: 15.4 }
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{
|
||||
segment: 'Regulares Matutinos',
|
||||
count: 145,
|
||||
percentage: 24.2,
|
||||
peakHours: ['07:00-09:00'],
|
||||
avgSpend: 12.50,
|
||||
frequency: 'Diaria'
|
||||
},
|
||||
{
|
||||
segment: 'Familia Fin de Semana',
|
||||
count: 198,
|
||||
percentage: 33.1,
|
||||
peakHours: ['10:00-13:00'],
|
||||
avgSpend: 28.90,
|
||||
frequency: 'Semanal'
|
||||
},
|
||||
{
|
||||
segment: 'Oficinistas Almuerzo',
|
||||
count: 112,
|
||||
percentage: 18.7,
|
||||
peakHours: ['12:00-14:00'],
|
||||
avgSpend: 8.75,
|
||||
frequency: '2-3x semana'
|
||||
},
|
||||
{
|
||||
segment: 'Clientes Ocasionales',
|
||||
count: 143,
|
||||
percentage: 23.9,
|
||||
peakHours: ['16:00-19:00'],
|
||||
avgSpend: 15.20,
|
||||
frequency: 'Mensual'
|
||||
}
|
||||
];
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
return trend >= 0 ? '↗' : '↘';
|
||||
};
|
||||
|
||||
const maxVisitors = Math.max(...hourlyTraffic.map(h => h.visitors));
|
||||
const maxDailyVisitors = Math.max(...dailyTraffic.map(d => d.visitors));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title={t('traffic:title', 'Análisis de Tráfico')}
|
||||
description={t('traffic:description', 'Monitorea los patrones de visitas y flujo de clientes')}
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Traffic Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Visitantes Totales</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{trafficData.totalVisitors.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Hora Pico</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{trafficData.peakHour}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Duración Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{trafficData.averageVisitDuration}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Conversión</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{trafficData.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Días Ocupados</p>
|
||||
<p className="text-sm font-bold text-[var(--color-error)]">{trafficData.busyDays.join(', ')}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="year">Este Año</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="visitors">Visitantes</option>
|
||||
<option value="sales">Ventas</option>
|
||||
<option value="duration">Duración</option>
|
||||
<option value="conversion">Conversión</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Hourly Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Tráfico por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{hourlyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-[var(--color-info)]/50 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxVisitors) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Daily Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Tráfico Semanal</h3>
|
||||
<div className="h-64 flex items-end space-x-2 justify-between">
|
||||
{dailyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-green-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxDailyVisitors) * 200}px`,
|
||||
minHeight: '8px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-sm text-[var(--text-secondary)] mt-2 font-medium">
|
||||
{data.day}
|
||||
</span>
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
{data.conversion}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Traffic Sources */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Fuentes de Tráfico</h3>
|
||||
<div className="space-y-3">
|
||||
{trafficSources.map((source, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-[var(--color-info)]/50 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{source.source}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{source.visitors} visitantes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{source.percentage}%</p>
|
||||
<div className={`text-xs flex items-center ${getTrendColor(source.trend)}`}>
|
||||
<span>{getTrendIcon(source.trend)} {Math.abs(source.trend).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{segment.segment}</h4>
|
||||
<Badge variant="blue">{segment.percentage}%</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Clientes</p>
|
||||
<p className="font-medium">{segment.count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Gasto Promedio</p>
|
||||
<p className="font-medium">€{segment.avgSpend}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Horario Pico</p>
|
||||
<p className="font-medium">{segment.peakHours.join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Frecuencia</p>
|
||||
<p className="font-medium">{segment.frequency}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Traffic Heat Map placeholder */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Mapa de Calor - Zonas de la Panadería</h3>
|
||||
<div className="h-64 bg-[var(--bg-tertiary)] rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">Visualización de zonas de mayor tráfico</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">Entrada: 45% • Mostrador: 32% • Zona sentada: 23%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPage;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as TrafficPage } from './TrafficPage';
|
||||
@@ -1,425 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cloud, Sun, CloudRain, Thermometer, Wind, Droplets, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const WeatherPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
|
||||
const currentWeather = {
|
||||
temperature: 18,
|
||||
condition: 'partly-cloudy',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
pressure: 1013,
|
||||
uvIndex: 4,
|
||||
visibility: 10,
|
||||
description: t('weather:conditions.partly_cloudy', 'Parcialmente nublado')
|
||||
};
|
||||
|
||||
const forecast = [
|
||||
{
|
||||
date: '2024-01-27',
|
||||
day: t('weather:days.saturday', 'Sábado'),
|
||||
condition: 'sunny',
|
||||
tempMax: 22,
|
||||
tempMin: 12,
|
||||
humidity: 45,
|
||||
precipitation: 0,
|
||||
wind: 8,
|
||||
impact: 'high-demand',
|
||||
recommendation: t('weather:recommendations.increase_ice_cream', 'Incrementar producción de helados y bebidas frías')
|
||||
},
|
||||
{
|
||||
date: '2024-01-28',
|
||||
day: t('weather:days.sunday', 'Domingo'),
|
||||
condition: 'partly-cloudy',
|
||||
tempMax: 19,
|
||||
tempMin: 11,
|
||||
humidity: 55,
|
||||
precipitation: 20,
|
||||
wind: 15,
|
||||
impact: 'normal',
|
||||
recommendation: t('weather:recommendations.standard_production', 'Producción estándar')
|
||||
},
|
||||
{
|
||||
date: '2024-01-29',
|
||||
day: t('weather:days.monday', 'Lunes'),
|
||||
condition: 'rainy',
|
||||
tempMax: 15,
|
||||
tempMin: 8,
|
||||
humidity: 85,
|
||||
precipitation: 80,
|
||||
wind: 22,
|
||||
impact: 'comfort-food',
|
||||
recommendation: t('weather:recommendations.comfort_foods', 'Aumentar sopas, chocolates calientes y pan recién horneado')
|
||||
},
|
||||
{
|
||||
date: '2024-01-30',
|
||||
day: t('weather:days.tuesday', 'Martes'),
|
||||
condition: 'cloudy',
|
||||
tempMax: 16,
|
||||
tempMin: 9,
|
||||
humidity: 70,
|
||||
precipitation: 40,
|
||||
wind: 18,
|
||||
impact: 'moderate',
|
||||
recommendation: t('weather:recommendations.indoor_focus', 'Enfoque en productos de interior')
|
||||
},
|
||||
{
|
||||
date: '2024-01-31',
|
||||
day: t('weather:days.wednesday', 'Miércoles'),
|
||||
condition: 'sunny',
|
||||
tempMax: 24,
|
||||
tempMin: 14,
|
||||
humidity: 40,
|
||||
precipitation: 0,
|
||||
wind: 10,
|
||||
impact: 'high-demand',
|
||||
recommendation: t('weather:recommendations.fresh_products', 'Incrementar productos frescos y ensaladas')
|
||||
}
|
||||
];
|
||||
|
||||
const weatherImpacts = [
|
||||
{
|
||||
condition: t('weather:impacts.sunny_day.condition', 'Día Soleado'),
|
||||
icon: Sun,
|
||||
impact: t('weather:impacts.sunny_day.impact', 'Aumento del 25% en bebidas frías'),
|
||||
recommendations: [
|
||||
t('weather:impacts.sunny_day.recommendations.0', 'Incrementar producción de helados'),
|
||||
t('weather:impacts.sunny_day.recommendations.1', 'Más bebidas refrescantes'),
|
||||
t('weather:impacts.sunny_day.recommendations.2', 'Ensaladas y productos frescos'),
|
||||
t('weather:impacts.sunny_day.recommendations.3', 'Horario extendido de terraza')
|
||||
],
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
condition: t('weather:impacts.rainy_day.condition', 'Día Lluvioso'),
|
||||
icon: CloudRain,
|
||||
impact: t('weather:impacts.rainy_day.impact', 'Aumento del 40% en productos calientes'),
|
||||
recommendations: [
|
||||
t('weather:impacts.rainy_day.recommendations.0', 'Más sopas y caldos'),
|
||||
t('weather:impacts.rainy_day.recommendations.1', 'Chocolates calientes'),
|
||||
t('weather:impacts.rainy_day.recommendations.2', 'Pan recién horneado'),
|
||||
t('weather:impacts.rainy_day.recommendations.3', 'Productos de repostería')
|
||||
],
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
condition: t('weather:impacts.cold_day.condition', 'Frío Intenso'),
|
||||
icon: Thermometer,
|
||||
impact: t('weather:impacts.cold_day.impact', 'Preferencia por comida reconfortante'),
|
||||
recommendations: [
|
||||
t('weather:impacts.cold_day.recommendations.0', 'Aumentar productos horneados'),
|
||||
t('weather:impacts.cold_day.recommendations.1', 'Bebidas calientes especiales'),
|
||||
t('weather:impacts.cold_day.recommendations.2', 'Productos energéticos'),
|
||||
t('weather:impacts.cold_day.recommendations.3', 'Promociones de interior')
|
||||
],
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
|
||||
const seasonalTrends = [
|
||||
{
|
||||
season: 'Primavera',
|
||||
period: 'Mar - May',
|
||||
trends: [
|
||||
'Aumento en productos frescos (+30%)',
|
||||
'Mayor demanda de ensaladas',
|
||||
'Bebidas naturales populares',
|
||||
'Horarios extendidos efectivos'
|
||||
],
|
||||
avgTemp: '15-20°C',
|
||||
impact: 'positive'
|
||||
},
|
||||
{
|
||||
season: 'Verano',
|
||||
period: 'Jun - Ago',
|
||||
trends: [
|
||||
'Pico de helados y granizados (+60%)',
|
||||
'Productos ligeros preferidos',
|
||||
'Horario matutino crítico',
|
||||
'Mayor tráfico de turistas'
|
||||
],
|
||||
avgTemp: '25-35°C',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
season: 'Otoño',
|
||||
period: 'Sep - Nov',
|
||||
trends: [
|
||||
'Regreso a productos tradicionales',
|
||||
'Aumento en bollería (+20%)',
|
||||
'Bebidas calientes populares',
|
||||
'Horarios regulares'
|
||||
],
|
||||
avgTemp: '10-18°C',
|
||||
impact: 'stable'
|
||||
},
|
||||
{
|
||||
season: 'Invierno',
|
||||
period: 'Dec - Feb',
|
||||
trends: [
|
||||
'Máximo de productos calientes (+50%)',
|
||||
'Pan recién horneado crítico',
|
||||
'Chocolates y dulces festivos',
|
||||
'Menor tráfico general (-15%)'
|
||||
],
|
||||
avgTemp: '5-12°C',
|
||||
impact: 'comfort'
|
||||
}
|
||||
];
|
||||
|
||||
const getWeatherIcon = (condition: string) => {
|
||||
const iconProps = { className: "w-8 h-8" };
|
||||
switch (condition) {
|
||||
case 'sunny': return <Sun {...iconProps} className="w-8 h-8 text-yellow-500" />;
|
||||
case 'partly-cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-[var(--text-tertiary)]" />;
|
||||
case 'cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-[var(--text-secondary)]" />;
|
||||
case 'rainy': return <CloudRain {...iconProps} className="w-8 h-8 text-blue-500" />;
|
||||
default: return <Cloud {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionLabel = (condition: string) => {
|
||||
switch (condition) {
|
||||
case 'sunny': return t('weather:conditions.sunny', 'Soleado');
|
||||
case 'partly-cloudy': return t('weather:conditions.partly_cloudy', 'Parcialmente nublado');
|
||||
case 'cloudy': return t('weather:conditions.cloudy', 'Nublado');
|
||||
case 'rainy': return t('weather:conditions.rainy', 'Lluvioso');
|
||||
default: return condition;
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'green';
|
||||
case 'comfort-food': return 'orange';
|
||||
case 'moderate': return 'blue';
|
||||
case 'normal': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactLabel = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return t('weather:impact.high_demand', 'Alta Demanda');
|
||||
case 'comfort-food': return t('weather:impact.comfort_food', 'Comida Reconfortante');
|
||||
case 'moderate': return t('weather:impact.moderate', 'Demanda Moderada');
|
||||
case 'normal': return t('weather:impact.normal', 'Demanda Normal');
|
||||
default: return impact;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title={t('weather:title', 'Datos Meteorológicos')}
|
||||
description={t('weather:description', 'Integra información del clima para optimizar la producción y ventas')}
|
||||
/>
|
||||
|
||||
{/* Current Weather */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">{t('weather:current.title', 'Condiciones Actuales')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{getWeatherIcon(currentWeather.condition)}
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{currentWeather.temperature}°C</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{currentWeather.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Droplets className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">{t('weather:current.humidity', 'Humedad')}: {currentWeather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wind className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">{t('weather:current.wind', 'Viento')}: {currentWeather.windSpeed} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">{t('weather:current.pressure', 'Presión')}:</span> {currentWeather.pressure} hPa
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">{t('weather:current.uv', 'UV')}:</span> {currentWeather.uvIndex}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">{t('weather:current.visibility', 'Visibilidad')}:</span> {currentWeather.visibility} km
|
||||
</div>
|
||||
<Badge variant="blue">{t('weather:current.favorable_conditions', 'Condiciones favorables')}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Forecast */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('weather:forecast.title', 'Pronóstico Extendido')}</h3>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
>
|
||||
<option value="week">{t('weather:forecast.next_week', 'Próxima Semana')}</option>
|
||||
<option value="month">{t('weather:forecast.next_month', 'Próximo Mes')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{forecast.map((day, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="text-center mb-3">
|
||||
<p className="font-medium text-[var(--text-primary)]">{day.day}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{new Date(day.date).toLocaleDateString('es-ES')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-3">
|
||||
{getWeatherIcon(day.condition)}
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-[var(--text-secondary)]">{getConditionLabel(day.condition)}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{day.tempMax}° <span className="text-sm text-[var(--text-tertiary)]">/ {day.tempMin}°</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('weather:current.humidity', 'Humedad')}:</span>
|
||||
<span>{day.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('weather:forecast.rain', 'Lluvia')}:</span>
|
||||
<span>{day.precipitation}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('weather:current.wind', 'Viento')}:</span>
|
||||
<span>{day.wind} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Badge variant={getImpactColor(day.impact)} className="text-xs">
|
||||
{getImpactLabel(day.impact)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-[var(--text-secondary)]">{day.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Impact Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">{t('weather:impact.title', 'Impacto del Clima')}</h3>
|
||||
<div className="space-y-4">
|
||||
{weatherImpacts.map((impact, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg bg-${impact.color}-100`}>
|
||||
<impact.icon className={`w-5 h-5 text-${impact.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{impact.condition}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{impact.impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-10">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)] mb-2">{t('weather:impact.recommendations', 'Recomendaciones')}:</p>
|
||||
<ul className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
{impact.recommendations.map((rec, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full mr-2"></span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Trends */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">{t('weather:seasonal.title', 'Tendencias Estacionales')}</h3>
|
||||
<div className="space-y-4">
|
||||
{seasonalTrends.map((season, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{season.season}</h4>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">{season.period}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">{season.avgTemp}</p>
|
||||
<Badge variant={
|
||||
season.impact === 'high' ? 'green' :
|
||||
season.impact === 'positive' ? 'blue' :
|
||||
season.impact === 'comfort' ? 'orange' : 'gray'
|
||||
}>
|
||||
{season.impact === 'high' ? 'Alto' :
|
||||
season.impact === 'positive' ? 'Positivo' :
|
||||
season.impact === 'comfort' ? 'Confort' : 'Estable'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
{season.trends.map((trend, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<TrendingUp className="w-3 h-3 mr-2 text-green-500" />
|
||||
{trend}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Weather Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">{t('weather:alerts.title', 'Alertas Meteorológicas')}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Sun className="w-5 h-5 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800">Ola de calor prevista</p>
|
||||
<p className="text-sm text-yellow-700">Se esperan temperaturas superiores a 30°C los próximos 3 días</p>
|
||||
<p className="text-xs text-yellow-600 mt-1">Recomendación: Incrementar stock de bebidas frías y helados</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-3 bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 rounded-lg">
|
||||
<CloudRain className="w-5 h-5 text-[var(--color-info)] mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-info)]">Lluvia intensa el lunes</p>
|
||||
<p className="text-sm text-[var(--color-info)]">80% probabilidad de precipitación con vientos fuertes</p>
|
||||
<p className="text-xs text-[var(--color-info)] mt-1">Recomendación: Preparar más productos calientes y de refugio</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherPage;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as WeatherPage } from './WeatherPage';
|
||||
@@ -40,6 +40,7 @@ const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales
|
||||
const ScenarioSimulationPage = React.lazy(() => import('../pages/app/analytics/scenario-simulation/ScenarioSimulationPage'));
|
||||
const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage'));
|
||||
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
|
||||
const EventRegistryPage = React.lazy(() => import('../pages/app/analytics/events/EventRegistryPage'));
|
||||
|
||||
|
||||
// Settings pages - Unified
|
||||
@@ -55,11 +56,6 @@ const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/M
|
||||
const QualityTemplatesPage = React.lazy(() => import('../pages/app/database/quality-templates/QualityTemplatesPage'));
|
||||
const SustainabilityPage = React.lazy(() => import('../pages/app/database/sustainability/SustainabilityPage'));
|
||||
|
||||
// Data pages
|
||||
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
|
||||
const TrafficPage = React.lazy(() => import('../pages/app/data/traffic/TrafficPage'));
|
||||
const EventsPage = React.lazy(() => import('../pages/app/data/events/EventsPage'));
|
||||
|
||||
// Onboarding pages
|
||||
const OnboardingPage = React.lazy(() => import('../pages/onboarding/OnboardingPage'));
|
||||
|
||||
@@ -331,6 +327,16 @@ export const AppRouter: React.FC = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/analytics/events"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['admin', 'owner']}>
|
||||
<AppShell>
|
||||
<EventRegistryPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
{/* Settings Routes */}
|
||||
@@ -370,38 +376,6 @@ export const AppRouter: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Data Routes */}
|
||||
<Route
|
||||
path="/app/data/weather"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<WeatherPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/data/traffic"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<TrafficPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/data/events"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<EventsPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Onboarding Route - Protected but without AppShell */}
|
||||
<Route
|
||||
path="/app/onboarding"
|
||||
|
||||
@@ -106,16 +106,10 @@ export const ROUTES = {
|
||||
POS_TRANSACTIONS: '/pos/transactions',
|
||||
POS_WEBHOOKS: '/pos/webhooks',
|
||||
POS_SETTINGS: '/pos/settings',
|
||||
|
||||
// Data Management
|
||||
DATA: '/app/data',
|
||||
DATA_IMPORT: '/data/import',
|
||||
DATA_EXPORT: '/data/export',
|
||||
DATA_EXTERNAL: '/data/external',
|
||||
DATA_WEATHER: '/app/data/weather',
|
||||
DATA_EVENTS: '/app/data/events',
|
||||
DATA_TRAFFIC: '/app/data/traffic',
|
||||
|
||||
|
||||
// Analytics
|
||||
ANALYTICS_EVENTS: '/app/analytics/events',
|
||||
|
||||
// Training & ML
|
||||
TRAINING: '/training',
|
||||
TRAINING_MODELS: '/training/models',
|
||||
@@ -374,6 +368,17 @@ export const routesConfig: RouteConfig[] = [
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/analytics/events',
|
||||
name: 'EventRegistry',
|
||||
component: 'EventRegistryPage',
|
||||
title: 'Registro de Eventos',
|
||||
icon: 'fileText',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ['admin', 'owner'],
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -536,50 +541,6 @@ export const routesConfig: RouteConfig[] = [
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
// Data Management Section
|
||||
{
|
||||
path: '/app/data',
|
||||
name: 'Data',
|
||||
component: 'DataPage',
|
||||
title: 'Gestión de Datos',
|
||||
icon: 'data',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
children: [
|
||||
{
|
||||
path: '/app/data/weather',
|
||||
name: 'Weather',
|
||||
component: 'WeatherPage',
|
||||
title: 'Datos Meteorológicos',
|
||||
icon: 'data',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/data/traffic',
|
||||
name: 'Traffic',
|
||||
component: 'TrafficPage',
|
||||
title: 'Datos de Tráfico',
|
||||
icon: 'data',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/data/events',
|
||||
name: 'Events',
|
||||
component: 'EventsPage',
|
||||
title: 'Eventos y Festivales',
|
||||
icon: 'data',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Onboarding Section - Complete 9-step flow
|
||||
{
|
||||
path: '/app/onboarding',
|
||||
|
||||
Reference in New Issue
Block a user