# ET-REPORT-FRONTEND: Componentes React

## Identificacion

| Campo | Valor |
|-------|-------|
| **ID** | ET-REPORT-FRONTEND |
| **Modulo** | MGN-009 Reports |
| **Version** | 1.0 |
| **Estado** | En Diseno |
| **Framework** | React + TypeScript |
| **UI Library** | shadcn/ui |
| **Charts** | Chart.js + react-chartjs-2 |
| **Grid** | react-grid-layout |
| **State** | Zustand |
| **Autor** | Requirements-Analyst |
| **Fecha** | 2025-12-05 |

---

## Descripcion General

Especificacion tecnica del modulo frontend de Reports. Incluye dashboards interactivos con drag-and-drop, report builder visual, visualizaciones Chart.js, exportacion multi-formato y reportes programados.

### Estructura de Archivos

```
apps/frontend/src/modules/reports/
├── index.ts
├── routes.tsx
├── pages/
│   ├── DashboardsPage.tsx
│   ├── DashboardViewPage.tsx
│   ├── DashboardEditPage.tsx
│   ├── ReportsPage.tsx
│   ├── ReportViewPage.tsx
│   ├── ReportBuilderPage.tsx
│   └── ScheduledReportsPage.tsx
├── components/
│   ├── dashboards/
│   │   ├── DashboardList.tsx
│   │   ├── DashboardCard.tsx
│   │   ├── DashboardGrid.tsx
│   │   ├── DashboardToolbar.tsx
│   │   ├── DashboardShare.tsx
│   │   └── DashboardFilters.tsx
│   ├── widgets/
│   │   ├── Widget.tsx
│   │   ├── WidgetWrapper.tsx
│   │   ├── WidgetHeader.tsx
│   │   ├── WidgetConfig.tsx
│   │   ├── WidgetPalette.tsx
│   │   ├── charts/
│   │   │   ├── LineChart.tsx
│   │   │   ├── BarChart.tsx
│   │   │   ├── PieChart.tsx
│   │   │   ├── AreaChart.tsx
│   │   │   ├── DonutChart.tsx
│   │   │   ├── GaugeChart.tsx
│   │   │   └── ChartConfig.tsx
│   │   ├── kpi/
│   │   │   ├── KpiCard.tsx
│   │   │   ├── KpiTrend.tsx
│   │   │   ├── KpiComparison.tsx
│   │   │   └── KpiSparkline.tsx
│   │   ├── tables/
│   │   │   ├── DataTable.tsx
│   │   │   ├── PivotTable.tsx
│   │   │   └── TableConfig.tsx
│   │   └── special/
│   │       ├── MapWidget.tsx
│   │       ├── FunnelWidget.tsx
│   │       ├── HeatmapWidget.tsx
│   │       └── TimelineWidget.tsx
│   ├── reports/
│   │   ├── ReportList.tsx
│   │   ├── ReportCard.tsx
│   │   ├── ReportViewer.tsx
│   │   ├── ReportParams.tsx
│   │   ├── ReportExport.tsx
│   │   └── ReportPrint.tsx
│   ├── builder/
│   │   ├── ReportBuilder.tsx
│   │   ├── DataSourcePicker.tsx
│   │   ├── FieldSelector.tsx
│   │   ├── FilterBuilder.tsx
│   │   ├── SortBuilder.tsx
│   │   ├── GroupByBuilder.tsx
│   │   ├── FormulaEditor.tsx
│   │   ├── LayoutDesigner.tsx
│   │   └── QueryPreview.tsx
│   ├── scheduled/
│   │   ├── ScheduleList.tsx
│   │   ├── ScheduleForm.tsx
│   │   ├── ScheduleHistory.tsx
│   │   └── CronBuilder.tsx
│   └── shared/
│       ├── DateRangePicker.tsx
│       ├── RefreshIndicator.tsx
│       ├── ExportButton.tsx
│       ├── FullscreenToggle.tsx
│       └── EmptyState.tsx
├── stores/
│   ├── dashboards.store.ts
│   ├── widgets.store.ts
│   ├── reports.store.ts
│   └── report-builder.store.ts
├── hooks/
│   ├── useDashboard.ts
│   ├── useWidget.ts
│   ├── useReport.ts
│   ├── useReportBuilder.ts
│   ├── useRefreshInterval.ts
│   └── useExport.ts
├── services/
│   ├── dashboards.service.ts
│   ├── widgets.service.ts
│   ├── reports.service.ts
│   └── export.service.ts
├── lib/
│   ├── chart-themes.ts
│   ├── chart-utils.ts
│   ├── export-utils.ts
│   └── grid-utils.ts
└── types/
    ├── dashboard.types.ts
    ├── widget.types.ts
    ├── report.types.ts
    └── builder.types.ts
```

---

## Types

### Dashboard Types

```typescript
// types/dashboard.types.ts

export interface Dashboard {
  id: string;
  tenantId: string;
  name: string;
  description?: string;
  category?: string;
  icon?: string;

  // Layout
  widgets: WidgetLayout[];
  gridCols: number;
  gridRowHeight: number;

  // Filters
  globalFilters?: DashboardFilter[];
  dateRange?: DateRange;

  // Settings
  refreshInterval?: number; // seconds, 0 = manual
  isPublic: boolean;
  isDefault: boolean;

  // Sharing
  sharedWith?: SharePermission[];

  // Metadata
  createdBy: string;
  createdAt: string;
  updatedAt: string;
}

export interface WidgetLayout {
  widgetId: string;
  x: number;
  y: number;
  w: number;
  h: number;
  minW?: number;
  minH?: number;
  static?: boolean;
}

export interface DashboardFilter {
  id: string;
  field: string;
  label: string;
  type: 'select' | 'multiselect' | 'date' | 'daterange' | 'text';
  options?: { value: string; label: string }[];
  defaultValue?: any;
  affectsWidgets: string[]; // widget IDs
}

export interface DateRange {
  type: 'relative' | 'absolute';
  relative?: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'thisMonth' | 'lastMonth' | 'thisYear';
  from?: string;
  to?: string;
}

export interface SharePermission {
  type: 'user' | 'role' | 'everyone';
  id?: string;
  permission: 'view' | 'edit';
}

export interface CreateDashboardDto {
  name: string;
  description?: string;
  category?: string;
  isPublic?: boolean;
}

export interface UpdateDashboardDto {
  name?: string;
  description?: string;
  widgets?: WidgetLayout[];
  globalFilters?: DashboardFilter[];
  refreshInterval?: number;
  isPublic?: boolean;
  isDefault?: boolean;
}
```

### Widget Types

```typescript
// types/widget.types.ts

export type WidgetType =
  | 'line_chart'
  | 'bar_chart'
  | 'pie_chart'
  | 'donut_chart'
  | 'area_chart'
  | 'gauge'
  | 'kpi'
  | 'kpi_trend'
  | 'table'
  | 'pivot'
  | 'map'
  | 'funnel'
  | 'heatmap'
  | 'timeline'
  | 'text'
  | 'image';

export interface Widget {
  id: string;
  tenantId: string;
  dashboardId: string;
  name: string;
  type: WidgetType;

  // Data
  dataSource: WidgetDataSource;
  data?: any; // Cached data

  // Config
  config: WidgetConfig;

  // Display
  title?: string;
  subtitle?: string;
  showTitle: boolean;
  showToolbar: boolean;

  // Refresh
  refreshInterval?: number;
  lastRefreshed?: string;

  createdAt: string;
  updatedAt: string;
}

export interface WidgetDataSource {
  type: 'report' | 'query' | 'api' | 'static';
  reportId?: string;
  query?: WidgetQuery;
  apiEndpoint?: string;
  staticData?: any;
}

export interface WidgetQuery {
  table: string;
  fields: string[];
  filters?: QueryFilter[];
  groupBy?: string[];
  orderBy?: { field: string; direction: 'asc' | 'desc' }[];
  limit?: number;
  aggregations?: Aggregation[];
}

export interface QueryFilter {
  field: string;
  operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'between';
  value: any;
  isDynamic?: boolean; // Links to dashboard filter
  dashboardFilterId?: string;
}

export interface Aggregation {
  field: string;
  function: 'sum' | 'avg' | 'count' | 'min' | 'max';
  alias: string;
}

export interface WidgetConfig {
  // Chart specific
  chart?: ChartConfig;

  // KPI specific
  kpi?: KpiConfig;

  // Table specific
  table?: TableConfig;

  // Colors
  colors?: string[];
  colorScheme?: 'default' | 'blue' | 'green' | 'purple' | 'warm' | 'cool';

  // Formatting
  numberFormat?: string;
  dateFormat?: string;
  currency?: string;
}

export interface ChartConfig {
  xField: string;
  yField: string | string[];
  seriesField?: string;
  legend?: boolean;
  legendPosition?: 'top' | 'bottom' | 'left' | 'right';
  showLabels?: boolean;
  showGrid?: boolean;
  smooth?: boolean; // for line/area
  stacked?: boolean; // for bar
  horizontal?: boolean; // for bar
  donutRadius?: number; // for donut
  minValue?: number;
  maxValue?: number;
  thresholds?: { value: number; color: string; label?: string }[];
}

export interface KpiConfig {
  valueField: string;
  label: string;
  format: 'number' | 'currency' | 'percent';
  decimals?: number;
  prefix?: string;
  suffix?: string;
  icon?: string;
  comparison?: {
    type: 'previous_period' | 'previous_year' | 'target';
    targetValue?: number;
    showChange: boolean;
    invertColors?: boolean;
  };
  sparkline?: {
    enabled: boolean;
    field: string;
    type: 'line' | 'bar';
  };
  thresholds?: {
    warning: number;
    danger: number;
  };
}

export interface TableConfig {
  columns: TableColumn[];
  pageSize?: number;
  sortable?: boolean;
  filterable?: boolean;
  exportable?: boolean;
  condensed?: boolean;
  striped?: boolean;
  rowClickAction?: 'expand' | 'navigate' | 'none';
  rowClickUrl?: string;
}

export interface TableColumn {
  field: string;
  header: string;
  width?: number;
  align?: 'left' | 'center' | 'right';
  format?: 'text' | 'number' | 'currency' | 'date' | 'percent' | 'badge';
  formatOptions?: Record<string, any>;
  sortable?: boolean;
  filterable?: boolean;
}

export interface CreateWidgetDto {
  name: string;
  type: WidgetType;
  dashboardId: string;
  dataSource: WidgetDataSource;
  config: WidgetConfig;
  title?: string;
}
```

### Report Types

```typescript
// types/report.types.ts

export type ReportFormat = 'pdf' | 'excel' | 'csv' | 'json';

export interface Report {
  id: string;
  tenantId: string;
  name: string;
  description?: string;
  category: string;

  // Definition
  type: 'predefined' | 'custom';
  definition: ReportDefinition;

  // Parameters
  parameters: ReportParameter[];

  // Output
  outputFormat: ReportFormat[];
  layout?: ReportLayout;

  // Stats
  runCount: number;
  lastRunAt?: string;
  averageRunTime?: number;

  // Status
  isActive: boolean;
  isFavorite: boolean;

  createdBy: string;
  createdAt: string;
  updatedAt: string;
}

export interface ReportDefinition {
  dataSource: string; // table or view name
  query: ReportQuery;
  columns: ReportColumn[];
  grouping?: ReportGrouping;
  totals?: ReportTotal[];
}

export interface ReportQuery {
  select: string[];
  where?: QueryFilter[];
  orderBy?: { field: string; direction: 'asc' | 'desc' }[];
  joins?: ReportJoin[];
}

export interface ReportJoin {
  table: string;
  alias?: string;
  type: 'inner' | 'left' | 'right';
  on: { left: string; right: string }[];
}

export interface ReportColumn {
  field: string;
  header: string;
  width?: number;
  format?: 'text' | 'number' | 'currency' | 'date' | 'percent';
  formatOptions?: Record<string, any>;
  alignment?: 'left' | 'center' | 'right';
  visible?: boolean;
  sortOrder?: number;
}

export interface ReportGrouping {
  fields: string[];
  showSubtotals: boolean;
  pageBreakAfter?: boolean;
}

export interface ReportTotal {
  field: string;
  function: 'sum' | 'avg' | 'count' | 'min' | 'max';
  label: string;
}

export interface ReportParameter {
  key: string;
  label: string;
  type: 'text' | 'number' | 'date' | 'daterange' | 'select' | 'multiselect';
  required: boolean;
  defaultValue?: any;
  options?: { value: any; label: string }[];
  validation?: {
    min?: number;
    max?: number;
    pattern?: string;
  };
}

export interface ReportLayout {
  pageSize: 'letter' | 'legal' | 'a4';
  orientation: 'portrait' | 'landscape';
  margins: { top: number; right: number; bottom: number; left: number };
  header?: ReportSection;
  footer?: ReportSection;
}

export interface ReportSection {
  height: number;
  content: string; // HTML or template
  showOnFirstPage?: boolean;
  showOnLastPage?: boolean;
}

export interface ReportRunRequest {
  reportId: string;
  parameters: Record<string, any>;
  format: ReportFormat;
}

export interface ReportRunResult {
  id: string;
  reportId: string;
  status: 'pending' | 'running' | 'completed' | 'failed';
  format: ReportFormat;
  fileUrl?: string;
  rowCount?: number;
  error?: string;
  startedAt: string;
  completedAt?: string;
}
```

### Scheduled Report Types

```typescript
// types/builder.types.ts

export interface ScheduledReport {
  id: string;
  tenantId: string;
  reportId: string;
  report?: Report;
  name: string;

  // Schedule
  cronExpression: string;
  timezone: string;
  isActive: boolean;

  // Parameters
  parameters: Record<string, any>;
  format: ReportFormat;

  // Delivery
  deliveryMethod: 'email' | 'storage' | 'both';
  recipients?: string[];
  storageLocation?: string;

  // Retention
  retentionDays?: number;

  // Stats
  lastRunAt?: string;
  nextRunAt?: string;
  runCount: number;
  failCount: number;

  createdBy: string;
  createdAt: string;
  updatedAt: string;
}

export interface ScheduleHistory {
  id: string;
  scheduledReportId: string;
  status: 'success' | 'failed' | 'partial';
  startedAt: string;
  completedAt: string;
  rowCount?: number;
  fileSize?: number;
  deliveredTo?: string[];
  error?: string;
}

export interface CreateScheduledReportDto {
  reportId: string;
  name: string;
  cronExpression: string;
  timezone: string;
  parameters: Record<string, any>;
  format: ReportFormat;
  deliveryMethod: 'email' | 'storage' | 'both';
  recipients?: string[];
}
```

---

## Stores (Zustand)

### Dashboards Store

```typescript
// stores/dashboards.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { Dashboard, WidgetLayout, DateRange, CreateDashboardDto, UpdateDashboardDto } from '../types/dashboard.types';
import { dashboardsService } from '../services/dashboards.service';

interface DashboardsState {
  // Data
  dashboards: Dashboard[];
  currentDashboard: Dashboard | null;

  // UI
  isLoading: boolean;
  isEditing: boolean;
  error: string | null;

  // Filters
  globalFilters: Record<string, any>;
  dateRange: DateRange | null;

  // Actions
  fetchDashboards: () => Promise<void>;
  fetchDashboard: (id: string) => Promise<void>;
  createDashboard: (dto: CreateDashboardDto) => Promise<Dashboard>;
  updateDashboard: (id: string, dto: UpdateDashboardDto) => Promise<void>;
  deleteDashboard: (id: string) => Promise<void>;
  duplicateDashboard: (id: string) => Promise<Dashboard>;

  // Layout
  updateLayout: (widgets: WidgetLayout[]) => void;
  setEditing: (editing: boolean) => void;

  // Filters
  setGlobalFilter: (key: string, value: any) => void;
  setDateRange: (range: DateRange) => void;
  clearFilters: () => void;
}

export const useDashboardsStore = create<DashboardsState>()(
  devtools(
    (set, get) => ({
      dashboards: [],
      currentDashboard: null,
      isLoading: false,
      isEditing: false,
      error: null,
      globalFilters: {},
      dateRange: null,

      fetchDashboards: async () => {
        set({ isLoading: true, error: null });
        try {
          const dashboards = await dashboardsService.getAll();
          set({ dashboards, isLoading: false });
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
        }
      },

      fetchDashboard: async (id: string) => {
        set({ isLoading: true, error: null });
        try {
          const dashboard = await dashboardsService.getById(id);
          set({
            currentDashboard: dashboard,
            dateRange: dashboard.dateRange ?? null,
            isLoading: false,
          });
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
        }
      },

      createDashboard: async (dto: CreateDashboardDto) => {
        set({ isLoading: true, error: null });
        try {
          const dashboard = await dashboardsService.create(dto);
          set((state) => ({
            dashboards: [...state.dashboards, dashboard],
            isLoading: false,
          }));
          return dashboard;
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
          throw error;
        }
      },

      updateDashboard: async (id: string, dto: UpdateDashboardDto) => {
        try {
          const dashboard = await dashboardsService.update(id, dto);
          set((state) => ({
            currentDashboard: state.currentDashboard?.id === id ? dashboard : state.currentDashboard,
            dashboards: state.dashboards.map((d) => (d.id === id ? dashboard : d)),
          }));
        } catch (error: any) {
          set({ error: error.message });
          throw error;
        }
      },

      deleteDashboard: async (id: string) => {
        try {
          await dashboardsService.delete(id);
          set((state) => ({
            dashboards: state.dashboards.filter((d) => d.id !== id),
            currentDashboard: state.currentDashboard?.id === id ? null : state.currentDashboard,
          }));
        } catch (error: any) {
          set({ error: error.message });
          throw error;
        }
      },

      duplicateDashboard: async (id: string) => {
        const dashboard = await dashboardsService.duplicate(id);
        set((state) => ({
          dashboards: [...state.dashboards, dashboard],
        }));
        return dashboard;
      },

      updateLayout: (widgets: WidgetLayout[]) => {
        const current = get().currentDashboard;
        if (!current) return;

        set({
          currentDashboard: { ...current, widgets },
        });
      },

      setEditing: (editing: boolean) => {
        set({ isEditing: editing });
      },

      setGlobalFilter: (key: string, value: any) => {
        set((state) => ({
          globalFilters: { ...state.globalFilters, [key]: value },
        }));
      },

      setDateRange: (range: DateRange) => {
        set({ dateRange: range });
      },

      clearFilters: () => {
        set({ globalFilters: {}, dateRange: null });
      },
    }),
    { name: 'dashboards-store' }
  )
);
```

---

## Custom Hooks

### useRefreshInterval

```typescript
// hooks/useRefreshInterval.ts
import { useEffect, useRef, useCallback, useState } from 'react';

interface UseRefreshIntervalOptions {
  interval: number; // seconds, 0 = disabled
  onRefresh: () => Promise<void>;
  enabled?: boolean;
}

interface UseRefreshIntervalReturn {
  isRefreshing: boolean;
  lastRefreshed: Date | null;
  refresh: () => Promise<void>;
  pause: () => void;
  resume: () => void;
  isPaused: boolean;
  secondsUntilRefresh: number;
}

export function useRefreshInterval({
  interval,
  onRefresh,
  enabled = true,
}: UseRefreshIntervalOptions): UseRefreshIntervalReturn {
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
  const [isPaused, setIsPaused] = useState(false);
  const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(interval);

  const intervalRef = useRef<NodeJS.Timeout>();
  const countdownRef = useRef<NodeJS.Timeout>();

  const refresh = useCallback(async () => {
    if (isRefreshing) return;

    setIsRefreshing(true);
    try {
      await onRefresh();
      setLastRefreshed(new Date());
      setSecondsUntilRefresh(interval);
    } finally {
      setIsRefreshing(false);
    }
  }, [onRefresh, interval, isRefreshing]);

  const pause = useCallback(() => {
    setIsPaused(true);
    if (intervalRef.current) clearInterval(intervalRef.current);
    if (countdownRef.current) clearInterval(countdownRef.current);
  }, []);

  const resume = useCallback(() => {
    setIsPaused(false);
  }, []);

  useEffect(() => {
    if (!enabled || interval === 0 || isPaused) {
      return;
    }

    // Countdown timer
    countdownRef.current = setInterval(() => {
      setSecondsUntilRefresh((prev) => Math.max(0, prev - 1));
    }, 1000);

    // Refresh timer
    intervalRef.current = setInterval(() => {
      refresh();
    }, interval * 1000);

    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
      if (countdownRef.current) clearInterval(countdownRef.current);
    };
  }, [interval, enabled, isPaused, refresh]);

  return {
    isRefreshing,
    lastRefreshed,
    refresh,
    pause,
    resume,
    isPaused,
    secondsUntilRefresh,
  };
}
```

### useExport

```typescript
// hooks/useExport.ts
import { useState, useCallback } from 'react';
import { exportService } from '../services/export.service';
import { ReportFormat } from '../types/report.types';
import { toast } from 'sonner';

interface UseExportOptions {
  filename: string;
  format: ReportFormat;
}

interface UseExportReturn {
  isExporting: boolean;
  progress: number;
  exportData: (data: any) => Promise<void>;
  exportReport: (reportId: string, params: Record<string, any>) => Promise<void>;
  cancel: () => void;
}

export function useExport(options: UseExportOptions): UseExportReturn {
  const [isExporting, setIsExporting] = useState(false);
  const [progress, setProgress] = useState(0);
  const abortController = useRef<AbortController | null>(null);

  const exportData = useCallback(async (data: any) => {
    setIsExporting(true);
    setProgress(0);
    abortController.current = new AbortController();

    try {
      const blob = await exportService.exportData({
        data,
        format: options.format,
        filename: options.filename,
        onProgress: setProgress,
        signal: abortController.current.signal,
      });

      // Download
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${options.filename}.${options.format}`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);

      toast.success('Exportacion completada');
    } catch (error: any) {
      if (error.name !== 'AbortError') {
        toast.error(`Error al exportar: ${error.message}`);
      }
    } finally {
      setIsExporting(false);
      setProgress(0);
    }
  }, [options.format, options.filename]);

  const exportReport = useCallback(async (reportId: string, params: Record<string, any>) => {
    setIsExporting(true);
    setProgress(0);
    abortController.current = new AbortController();

    try {
      const result = await exportService.exportReport({
        reportId,
        parameters: params,
        format: options.format,
        onProgress: setProgress,
        signal: abortController.current.signal,
      });

      // Download from URL
      window.open(result.fileUrl, '_blank');
      toast.success('Exportacion completada');
    } catch (error: any) {
      if (error.name !== 'AbortError') {
        toast.error(`Error al exportar: ${error.message}`);
      }
    } finally {
      setIsExporting(false);
      setProgress(0);
    }
  }, [options.format]);

  const cancel = useCallback(() => {
    abortController.current?.abort();
  }, []);

  return {
    isExporting,
    progress,
    exportData,
    exportReport,
    cancel,
  };
}
```

---

## Components

### DashboardGrid

```tsx
// components/dashboards/DashboardGrid.tsx
import { useCallback, useMemo } from 'react';
import GridLayout, { Layout } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { WidgetWrapper } from '../widgets/WidgetWrapper';
import { useDashboardsStore } from '../../stores/dashboards.store';
import { useWidgetsStore } from '../../stores/widgets.store';
import { WidgetLayout } from '../../types/dashboard.types';

interface DashboardGridProps {
  dashboardId: string;
}

export function DashboardGrid({ dashboardId }: DashboardGridProps) {
  const { currentDashboard, isEditing, updateLayout, globalFilters, dateRange } = useDashboardsStore();
  const { widgets } = useWidgetsStore();

  const layout: Layout[] = useMemo(() => {
    if (!currentDashboard) return [];

    return currentDashboard.widgets.map((w) => ({
      i: w.widgetId,
      x: w.x,
      y: w.y,
      w: w.w,
      h: w.h,
      minW: w.minW ?? 2,
      minH: w.minH ?? 2,
      static: !isEditing || w.static,
    }));
  }, [currentDashboard, isEditing]);

  const handleLayoutChange = useCallback((newLayout: Layout[]) => {
    if (!isEditing) return;

    const widgetLayouts: WidgetLayout[] = newLayout.map((l) => ({
      widgetId: l.i,
      x: l.x,
      y: l.y,
      w: l.w,
      h: l.h,
    }));

    updateLayout(widgetLayouts);
  }, [isEditing, updateLayout]);

  if (!currentDashboard) return null;

  return (
    <GridLayout
      className="dashboard-grid"
      layout={layout}
      cols={currentDashboard.gridCols ?? 12}
      rowHeight={currentDashboard.gridRowHeight ?? 100}
      width={1200}
      onLayoutChange={handleLayoutChange}
      isDraggable={isEditing}
      isResizable={isEditing}
      compactType="vertical"
      preventCollision={false}
      margin={[16, 16]}
    >
      {currentDashboard.widgets.map((widgetLayout) => {
        const widget = widgets.find((w) => w.id === widgetLayout.widgetId);
        if (!widget) return null;

        return (
          <div key={widgetLayout.widgetId}>
            <WidgetWrapper
              widget={widget}
              isEditing={isEditing}
              globalFilters={globalFilters}
              dateRange={dateRange}
            />
          </div>
        );
      })}
    </GridLayout>
  );
}
```

### KpiCard

```tsx
// components/widgets/kpi/KpiCard.tsx
import { useMemo } from 'react';
import { ArrowUp, ArrowDown, Minus, TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { KpiSparkline } from './KpiSparkline';
import { Widget, KpiConfig } from '../../../types/widget.types';
import { cn, formatNumber, formatCurrency, formatPercent } from '@/lib/utils';

interface KpiCardProps {
  widget: Widget;
  data: any;
  config: KpiConfig;
}

export function KpiCard({ widget, data, config }: KpiCardProps) {
  const value = data?.[config.valueField] ?? 0;

  const formattedValue = useMemo(() => {
    switch (config.format) {
      case 'currency':
        return formatCurrency(value, { currency: config.currency ?? 'MXN', decimals: config.decimals });
      case 'percent':
        return formatPercent(value, { decimals: config.decimals });
      default:
        return formatNumber(value, { decimals: config.decimals });
    }
  }, [value, config]);

  // Comparison
  const comparison = useMemo(() => {
    if (!config.comparison) return null;

    let previousValue: number;
    switch (config.comparison.type) {
      case 'previous_period':
        previousValue = data?.previousPeriodValue ?? 0;
        break;
      case 'previous_year':
        previousValue = data?.previousYearValue ?? 0;
        break;
      case 'target':
        previousValue = config.comparison.targetValue ?? 0;
        break;
    }

    if (previousValue === 0) return null;

    const change = ((value - previousValue) / previousValue) * 100;
    const isPositive = change > 0;
    const isNegative = change < 0;

    return {
      change,
      isPositive: config.comparison.invertColors ? !isPositive : isPositive,
      isNegative: config.comparison.invertColors ? !isNegative : isNegative,
      label: config.comparison.type === 'target' ? 'vs objetivo' :
             config.comparison.type === 'previous_year' ? 'vs año ant.' : 'vs periodo ant.',
    };
  }, [value, data, config.comparison]);

  // Threshold status
  const status = useMemo(() => {
    if (!config.thresholds) return 'normal';
    if (value <= config.thresholds.danger) return 'danger';
    if (value <= config.thresholds.warning) return 'warning';
    return 'success';
  }, [value, config.thresholds]);

  const statusColors = {
    normal: 'text-foreground',
    success: 'text-green-600',
    warning: 'text-amber-600',
    danger: 'text-red-600',
  };

  return (
    <Card className="h-full">
      <CardContent className="p-6 flex flex-col justify-between h-full">
        <div>
          {/* Label */}
          <div className="flex items-center gap-2 text-sm text-muted-foreground">
            {config.icon && <span className="text-lg">{config.icon}</span>}
            <span>{config.label}</span>
          </div>

          {/* Value */}
          <div className={cn('text-3xl font-bold mt-2', statusColors[status])}>
            {config.prefix}
            {formattedValue}
            {config.suffix}
          </div>

          {/* Comparison */}
          {comparison && config.comparison?.showChange && (
            <div className="flex items-center gap-1 mt-2">
              {comparison.change === 0 ? (
                <Minus className="h-4 w-4 text-muted-foreground" />
              ) : comparison.isPositive ? (
                <ArrowUp className="h-4 w-4 text-green-600" />
              ) : (
                <ArrowDown className="h-4 w-4 text-red-600" />
              )}
              <span
                className={cn(
                  'text-sm font-medium',
                  comparison.isPositive ? 'text-green-600' :
                  comparison.isNegative ? 'text-red-600' : 'text-muted-foreground'
                )}
              >
                {Math.abs(comparison.change).toFixed(1)}%
              </span>
              <span className="text-sm text-muted-foreground">
                {comparison.label}
              </span>
            </div>
          )}
        </div>

        {/* Sparkline */}
        {config.sparkline?.enabled && data?.sparklineData && (
          <div className="mt-4 h-12">
            <KpiSparkline
              data={data.sparklineData}
              type={config.sparkline.type}
              color={comparison?.isPositive ? '#22c55e' : comparison?.isNegative ? '#ef4444' : '#6b7280'}
            />
          </div>
        )}
      </CardContent>
    </Card>
  );
}
```

### ReportBuilder

```tsx
// components/builder/ReportBuilder.tsx
import { useState } from 'react';
import { Save, Play, Eye, Code } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { DataSourcePicker } from './DataSourcePicker';
import { FieldSelector } from './FieldSelector';
import { FilterBuilder } from './FilterBuilder';
import { SortBuilder } from './SortBuilder';
import { GroupByBuilder } from './GroupByBuilder';
import { FormulaEditor } from './FormulaEditor';
import { LayoutDesigner } from './LayoutDesigner';
import { QueryPreview } from './QueryPreview';
import { useReportBuilderStore } from '../../stores/report-builder.store';

export function ReportBuilder() {
  const [activeTab, setActiveTab] = useState('data');
  const {
    dataSource,
    selectedFields,
    filters,
    sortBy,
    groupBy,
    preview,
    isLoading,
    runPreview,
    saveReport,
  } = useReportBuilderStore();

  return (
    <div className="h-[calc(100vh-200px)]">
      <ResizablePanelGroup direction="horizontal">
        {/* Left Panel - Builder */}
        <ResizablePanel defaultSize={60} minSize={40}>
          <Card className="h-full rounded-none border-0">
            <CardHeader className="flex flex-row items-center justify-between py-3 border-b">
              <CardTitle className="text-lg">Report Builder</CardTitle>
              <div className="flex gap-2">
                <Button variant="outline" size="sm" onClick={runPreview} disabled={isLoading}>
                  <Play className="mr-2 h-4 w-4" />
                  Preview
                </Button>
                <Button size="sm" onClick={saveReport}>
                  <Save className="mr-2 h-4 w-4" />
                  Guardar
                </Button>
              </div>
            </CardHeader>

            <CardContent className="p-0">
              <Tabs value={activeTab} onValueChange={setActiveTab}>
                <TabsList className="grid w-full grid-cols-5 rounded-none border-b">
                  <TabsTrigger value="data">Datos</TabsTrigger>
                  <TabsTrigger value="filters">Filtros</TabsTrigger>
                  <TabsTrigger value="grouping">Agrupacion</TabsTrigger>
                  <TabsTrigger value="formulas">Formulas</TabsTrigger>
                  <TabsTrigger value="layout">Diseno</TabsTrigger>
                </TabsList>

                <div className="p-4 overflow-auto h-[calc(100%-40px)]">
                  <TabsContent value="data" className="m-0 space-y-4">
                    <DataSourcePicker />
                    <FieldSelector />
                    <SortBuilder />
                  </TabsContent>

                  <TabsContent value="filters" className="m-0">
                    <FilterBuilder />
                  </TabsContent>

                  <TabsContent value="grouping" className="m-0">
                    <GroupByBuilder />
                  </TabsContent>

                  <TabsContent value="formulas" className="m-0">
                    <FormulaEditor />
                  </TabsContent>

                  <TabsContent value="layout" className="m-0">
                    <LayoutDesigner />
                  </TabsContent>
                </div>
              </Tabs>
            </CardContent>
          </Card>
        </ResizablePanel>

        <ResizableHandle withHandle />

        {/* Right Panel - Preview */}
        <ResizablePanel defaultSize={40} minSize={30}>
          <Card className="h-full rounded-none border-0 border-l">
            <CardHeader className="py-3 border-b">
              <div className="flex items-center justify-between">
                <CardTitle className="text-lg flex items-center gap-2">
                  <Eye className="h-5 w-5" />
                  Vista Previa
                </CardTitle>
                <Button variant="ghost" size="sm">
                  <Code className="h-4 w-4" />
                </Button>
              </div>
            </CardHeader>

            <CardContent className="p-0 h-[calc(100%-60px)] overflow-auto">
              <Tabs defaultValue="results">
                <TabsList className="grid w-full grid-cols-2 rounded-none border-b">
                  <TabsTrigger value="results">Resultados</TabsTrigger>
                  <TabsTrigger value="query">SQL</TabsTrigger>
                </TabsList>

                <TabsContent value="results" className="m-0 p-4">
                  <QueryPreview data={preview} isLoading={isLoading} />
                </TabsContent>

                <TabsContent value="query" className="m-0 p-4">
                  <pre className="text-sm bg-muted p-4 rounded-md overflow-auto">
                    {/* Generated SQL preview */}
                  </pre>
                </TabsContent>
              </Tabs>
            </CardContent>
          </Card>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  );
}
```

### CronBuilder

```tsx
// components/scheduled/CronBuilder.tsx
import { useState, useEffect, useMemo } from 'react';
import { Clock } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { parseCron, generateCron, describeCron } from '../../lib/cron-utils';

interface CronBuilderProps {
  value: string;
  onChange: (value: string) => void;
}

type Frequency = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom';

export function CronBuilder({ value, onChange }: CronBuilderProps) {
  const [frequency, setFrequency] = useState<Frequency>('daily');
  const [hour, setHour] = useState(9);
  const [minute, setMinute] = useState(0);
  const [weekDays, setWeekDays] = useState<number[]>([1, 2, 3, 4, 5]); // Mon-Fri
  const [monthDay, setMonthDay] = useState(1);
  const [customCron, setCustomCron] = useState('');

  // Parse initial value
  useEffect(() => {
    if (value) {
      const parsed = parseCron(value);
      if (parsed) {
        setFrequency(parsed.frequency as Frequency);
        setHour(parsed.hour);
        setMinute(parsed.minute);
        if (parsed.weekDays) setWeekDays(parsed.weekDays);
        if (parsed.monthDay) setMonthDay(parsed.monthDay);
      }
    }
  }, []);

  // Generate cron on changes
  useEffect(() => {
    if (frequency === 'custom') {
      onChange(customCron);
      return;
    }

    const cron = generateCron({
      frequency,
      hour,
      minute,
      weekDays: frequency === 'weekly' ? weekDays : undefined,
      monthDay: frequency === 'monthly' ? monthDay : undefined,
    });

    onChange(cron);
  }, [frequency, hour, minute, weekDays, monthDay, customCron, onChange]);

  const description = useMemo(() => {
    try {
      return describeCron(frequency === 'custom' ? customCron : value);
    } catch {
      return 'Expresion no valida';
    }
  }, [value, frequency, customCron]);

  const weekDayLabels = ['Dom', 'Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab'];

  return (
    <div className="space-y-4">
      {/* Frequency */}
      <div className="space-y-2">
        <Label>Frecuencia</Label>
        <Select value={frequency} onValueChange={(v) => setFrequency(v as Frequency)}>
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="hourly">Cada hora</SelectItem>
            <SelectItem value="daily">Diario</SelectItem>
            <SelectItem value="weekly">Semanal</SelectItem>
            <SelectItem value="monthly">Mensual</SelectItem>
            <SelectItem value="custom">Personalizado</SelectItem>
          </SelectContent>
        </Select>
      </div>

      {/* Time (not for hourly) */}
      {frequency !== 'hourly' && frequency !== 'custom' && (
        <div className="grid grid-cols-2 gap-4">
          <div className="space-y-2">
            <Label>Hora</Label>
            <Select value={hour.toString()} onValueChange={(v) => setHour(parseInt(v))}>
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {[...Array(24)].map((_, i) => (
                  <SelectItem key={i} value={i.toString()}>
                    {i.toString().padStart(2, '0')}:00
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          <div className="space-y-2">
            <Label>Minuto</Label>
            <Select value={minute.toString()} onValueChange={(v) => setMinute(parseInt(v))}>
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {[0, 15, 30, 45].map((m) => (
                  <SelectItem key={m} value={m.toString()}>
                    :{m.toString().padStart(2, '0')}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
        </div>
      )}

      {/* Week days (for weekly) */}
      {frequency === 'weekly' && (
        <div className="space-y-2">
          <Label>Dias de la semana</Label>
          <div className="flex gap-1">
            {weekDayLabels.map((label, index) => (
              <Button
                key={index}
                type="button"
                variant={weekDays.includes(index) ? 'default' : 'outline'}
                size="sm"
                className="w-10"
                onClick={() => {
                  if (weekDays.includes(index)) {
                    setWeekDays(weekDays.filter((d) => d !== index));
                  } else {
                    setWeekDays([...weekDays, index].sort());
                  }
                }}
              >
                {label}
              </Button>
            ))}
          </div>
        </div>
      )}

      {/* Month day (for monthly) */}
      {frequency === 'monthly' && (
        <div className="space-y-2">
          <Label>Dia del mes</Label>
          <Select value={monthDay.toString()} onValueChange={(v) => setMonthDay(parseInt(v))}>
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {[...Array(31)].map((_, i) => (
                <SelectItem key={i + 1} value={(i + 1).toString()}>
                  Dia {i + 1}
                </SelectItem>
              ))}
              <SelectItem value="-1">Ultimo dia</SelectItem>
            </SelectContent>
          </Select>
        </div>
      )}

      {/* Custom cron */}
      {frequency === 'custom' && (
        <div className="space-y-2">
          <Label>Expresion Cron</Label>
          <Input
            value={customCron}
            onChange={(e) => setCustomCron(e.target.value)}
            placeholder="0 9 * * 1-5"
          />
          <p className="text-xs text-muted-foreground">
            Formato: minuto hora dia-mes mes dia-semana
          </p>
        </div>
      )}

      {/* Description */}
      <div className="flex items-center gap-2 p-3 bg-muted rounded-md">
        <Clock className="h-4 w-4 text-muted-foreground" />
        <span className="text-sm">{description}</span>
      </div>
    </div>
  );
}
```

---

## Routes

```tsx
// routes.tsx
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';

const DashboardsPage = lazy(() => import('./pages/DashboardsPage'));
const DashboardViewPage = lazy(() => import('./pages/DashboardViewPage'));
const DashboardEditPage = lazy(() => import('./pages/DashboardEditPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
const ReportViewPage = lazy(() => import('./pages/ReportViewPage'));
const ReportBuilderPage = lazy(() => import('./pages/ReportBuilderPage'));
const ScheduledReportsPage = lazy(() => import('./pages/ScheduledReportsPage'));

export const reportsRoutes: RouteObject[] = [
  {
    path: 'reports',
    children: [
      {
        index: true,
        element: <ReportsPage />,
      },
      {
        path: 'dashboards',
        element: <DashboardsPage />,
      },
      {
        path: 'dashboards/:id',
        element: <DashboardViewPage />,
      },
      {
        path: 'dashboards/:id/edit',
        element: <DashboardEditPage />,
      },
      {
        path: 'view/:id',
        element: <ReportViewPage />,
      },
      {
        path: 'builder',
        element: <ReportBuilderPage />,
      },
      {
        path: 'builder/:id',
        element: <ReportBuilderPage />,
      },
      {
        path: 'scheduled',
        element: <ScheduledReportsPage />,
      },
    ],
  },
];
```

---

## Wireframes

### Dashboard

```
+------------------------------------------------------------------+
| Ventas Dashboard                      [Editar] [Compartir] [...]  |
| Actualizado hace 5 min           [Hoy v] [Este mes v] [Refrescar] |
+------------------------------------------------------------------+

+----------------+ +----------------+ +----------------+ +----------------+
| $1,234,567     | | 156            | | 87.5%          | | $7,921         |
| Ventas Totales | | Ordenes        | | Meta Cumplida  | | Ticket Prom    |
| +15.2% vs ant  | | +23 vs ant     | | [========>  ]  | | -2.3% vs ant   |
| [sparkline]    | | [sparkline]    | |                | | [sparkline]    |
+----------------+ +----------------+ +----------------+ +----------------+

+-------------------------------------+ +---------------------------+
| Ventas por Mes                      | | Top Productos             |
| $                                   | | +----------------------+  |
| 150k |      __                      | | | Producto A   $45,000 |  |
| 100k |   __/  \__                   | | | Producto B   $38,500 |  |
|  50k | _/        \_                 | | | Producto C   $32,000 |  |
|      +--E--F--M--A--M--J--         | | | Producto D   $28,000 |  |
|                                     | | | Producto E   $25,000 |  |
+-------------------------------------+ +---------------------------+

+-------------------------------------+ +---------------------------+
| Ventas por Region          [🍩]     | | Ultimas Ordenes           |
|                                     | | #1234 Cliente A  $1,200   |
|     Norte 35%   [====]              | | #1233 Cliente B  $890     |
|     Centro 28%  [===]               | | #1232 Cliente C  $2,100   |
|     Sur 22%     [==]                | | #1231 Cliente D  $450     |
|     Este 15%    [=]                 | | #1230 Cliente E  $3,200   |
|                                     | |              [Ver todas]   |
+-------------------------------------+ +---------------------------+
```

### Report Builder

```
+------------------------------------------------------------------+
| Report Builder                              [Preview] [Guardar]   |
+------------------------------------------------------------------+
| [Datos] [Filtros] [Agrupacion] [Formulas] [Diseno]               |
+------------------------------------------------------------------+
|                                   |                               |
| Fuente de Datos                   | Vista Previa        [SQL]     |
| [Ordenes de Venta     v]          |                               |
|                                   | +---------------------------+ |
| Campos Disponibles    Seleccion   | | ID   | Cliente | Total   | |
| [x] order_number      [order_num] | |------|---------|---------|  |
| [x] customer_name     [customer]  | | 1234 | ABC Inc | $1,200  | |
| [ ] order_date        [total]     | | 1235 | XYZ Ltd | $890    | |
| [x] total_amount                  | | 1236 | 123 Co  | $2,100  | |
| [ ] status                        | +---------------------------+ |
| [ ] payment_method                | Mostrando 3 de 156 registros  |
|                                   |                               |
| Ordenar Por                       |                               |
| [order_date v] [DESC v] [+]       |                               |
|                                   |                               |
+------------------------------------------------------------------+
```

### Reportes Programados

```
+------------------------------------------------------------------+
| Reportes Programados                         [+ Nuevo Programado]  |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| [ON O] Ventas Diarias                                             |
|        Diario a las 8:00 AM                                       |
|        Proxima ejecucion: Manana 8:00 AM                          |
|        Destinatarios: gerencia@empresa.com, ventas@empresa.com    |
|                                            [Historial] [Editar]   |
+------------------------------------------------------------------+
| [ON O] Inventario Semanal                                         |
|        Lunes a las 7:00 AM                                        |
|        Proxima ejecucion: Lunes 7:00 AM                           |
|        Destinatarios: almacen@empresa.com                         |
|                                            [Historial] [Editar]   |
+------------------------------------------------------------------+
| [OFF ] Cierre Mensual                                             |
|        Dia 1 de cada mes a las 6:00 AM                            |
|        Ultima ejecucion: 01/12/2025 - Exitoso                     |
|        Destinatarios: contabilidad@empresa.com                    |
|                                            [Historial] [Editar]   |
+------------------------------------------------------------------+
```

---

## Historial de Cambios

| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |

---

## Aprobaciones

| Rol | Nombre | Fecha | Firma |
|-----|--------|-------|-------|
| Frontend Lead | - | - | [ ] |
| UX Designer | - | - | [ ] |
