Merge pull request 'feature/dashboard' (#53) from feature/dashboard into development
Reviewed-on: ADUANASOFT/anexo76#53
This commit is contained in:
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de dashboard para estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
109
backend/api/v1/modules/core/dashboard/dto.py
Normal file
109
backend/api/v1/modules/core/dashboard/dto.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
DTOs para el dashboard de estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class KPIMetric(BaseModel):
|
||||
"""Métrica individual de KPI"""
|
||||
|
||||
label: str = Field(..., description="Nombre del indicador")
|
||||
value: int | float = Field(..., description="Valor actual")
|
||||
previous_value: Optional[int | float] = Field(
|
||||
None, description="Valor anterior para comparación"
|
||||
)
|
||||
percentage_change: Optional[float] = Field(None, description="Porcentaje de cambio")
|
||||
trend: Optional[str] = Field(None, description="up, down, stable")
|
||||
unit: Optional[str] = Field(None, description="Unidad de medida (%, USD, etc)")
|
||||
|
||||
|
||||
class ActivityItem(BaseModel):
|
||||
"""Item de actividad reciente"""
|
||||
|
||||
id: int
|
||||
type: str = Field(
|
||||
..., description="Tipo de actividad: invoice, pedimento, client, etc"
|
||||
)
|
||||
title: str = Field(..., description="Título descriptivo")
|
||||
description: Optional[str] = Field(None, description="Descripción adicional")
|
||||
timestamp: datetime
|
||||
status: Optional[str] = Field(None, description="Estado del item")
|
||||
icon: Optional[str] = Field(None, description="Icono a mostrar")
|
||||
|
||||
|
||||
class ChartDataPoint(BaseModel):
|
||||
"""Punto de datos para gráficas"""
|
||||
|
||||
label: str
|
||||
value: float
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Estadísticas generales del dashboard"""
|
||||
|
||||
# KPIs principales
|
||||
total_invoices: KPIMetric
|
||||
total_pedimentos: KPIMetric
|
||||
total_clients: KPIMetric
|
||||
total_providers: KPIMetric
|
||||
active_items: KPIMetric
|
||||
pending_approvals: KPIMetric
|
||||
|
||||
# Estadísticas financieras
|
||||
total_value_imports: Optional[float] = Field(
|
||||
None, description="Valor total de importaciones"
|
||||
)
|
||||
total_value_exports: Optional[float] = Field(
|
||||
None, description="Valor total de exportaciones"
|
||||
)
|
||||
|
||||
# Datos para gráficas
|
||||
invoices_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||
pedimentos_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||
operations_by_type: List[ChartDataPoint] = Field(default_factory=list)
|
||||
top_clients: List[ChartDataPoint] = Field(default_factory=list)
|
||||
top_providers: List[ChartDataPoint] = Field(default_factory=list)
|
||||
|
||||
# Actividad reciente
|
||||
recent_activity: List[ActivityItem] = Field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
company_id: int
|
||||
company_name: Optional[str] = None
|
||||
|
||||
|
||||
class OperationsOverview(BaseModel):
|
||||
"""Vista general de operaciones"""
|
||||
|
||||
total_operations: int
|
||||
by_type: Dict[str, int] = Field(default_factory=dict)
|
||||
by_status: Dict[str, int] = Field(default_factory=dict)
|
||||
avg_processing_time: Optional[float] = Field(
|
||||
None, description="Tiempo promedio en días"
|
||||
)
|
||||
|
||||
|
||||
class InventoryMetrics(BaseModel):
|
||||
"""Métricas de inventario"""
|
||||
|
||||
total_items: int
|
||||
items_in_stock: int
|
||||
items_low_stock: int
|
||||
total_value: Optional[float] = None
|
||||
by_category: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ComplianceMetrics(BaseModel):
|
||||
"""Métricas de cumplimiento normativo"""
|
||||
|
||||
pending_documents: int
|
||||
expired_permits: int
|
||||
upcoming_deadlines: int
|
||||
compliance_score: Optional[float] = Field(
|
||||
None, description="Score de cumplimiento 0-100"
|
||||
)
|
||||
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Endpoints del dashboard para estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from .dto import DashboardStats, OperationsOverview, InventoryMetrics
|
||||
from .service import DashboardService
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas completas del dashboard para la compañía especificada.
|
||||
|
||||
Incluye:
|
||||
- KPIs principales (facturas, pedimentos, clientes, proveedores, items)
|
||||
- Gráficas de tendencias (facturas por mes, operaciones por tipo)
|
||||
- Top clientes y proveedores
|
||||
- Actividad reciente
|
||||
"""
|
||||
# Validar acceso
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
# Generar estadísticas
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
stats = service.get_complete_dashboard_stats()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/operations-overview", response_model=OperationsOverview)
|
||||
async def get_operations_overview(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene una vista general de las operaciones
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
|
||||
# Implementación básica
|
||||
ops_by_type = service.get_operations_by_type()
|
||||
|
||||
return OperationsOverview(
|
||||
total_operations=sum(int(op.value) for op in ops_by_type),
|
||||
by_type={op.label: int(op.value) for op in ops_by_type},
|
||||
by_status={},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inventory-metrics", response_model=InventoryMetrics)
|
||||
async def get_inventory_metrics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene métricas de inventario
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
items_kpi = service.get_items_metrics()
|
||||
|
||||
return InventoryMetrics(
|
||||
total_items=int(items_kpi.value),
|
||||
items_in_stock=int(items_kpi.value), # Simplificado
|
||||
items_low_stock=0,
|
||||
by_category={},
|
||||
)
|
||||
428
backend/api/v1/modules/core/dashboard/service.py
Normal file
428
backend/api/v1/modules/core/dashboard/service.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Servicio para generar estadísticas y métricas del dashboard
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
|
||||
from api.v1.modules.a76.invoices.models import (
|
||||
InvoiceHeader,
|
||||
InvoiceComplianceMx,
|
||||
OperationType,
|
||||
)
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider,
|
||||
ClientOrProviderEnum,
|
||||
)
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
||||
|
||||
from .dto import (
|
||||
DashboardStats,
|
||||
KPIMetric,
|
||||
ActivityItem,
|
||||
ChartDataPoint,
|
||||
OperationsOverview,
|
||||
InventoryMetrics,
|
||||
)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Servicio para generar estadísticas del dashboard"""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: int, company_id: int):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
self.company_id = company_id
|
||||
|
||||
def _calculate_trend(self, current: int, previous: int) -> tuple[float, str]:
|
||||
"""Calcula el cambio porcentual y la tendencia"""
|
||||
if previous == 0:
|
||||
return 0.0, "stable" if current == 0 else "up"
|
||||
|
||||
change = ((current - previous) / previous) * 100
|
||||
|
||||
if abs(change) < 1:
|
||||
trend = "stable"
|
||||
elif change > 0:
|
||||
trend = "up"
|
||||
else:
|
||||
trend = "down"
|
||||
|
||||
return round(change, 2), trend
|
||||
|
||||
def get_invoice_metrics(self) -> tuple[KPIMetric, List[ChartDataPoint]]:
|
||||
"""Obtiene métricas de facturas"""
|
||||
# Total de facturas actuales
|
||||
current_total = (
|
||||
self.db.query(func.count(InvoiceHeader.id))
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total del mes anterior para comparación
|
||||
last_month = datetime.utcnow() - timedelta(days=30)
|
||||
previous_total = (
|
||||
self.db.query(func.count(InvoiceHeader.id))
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
InvoiceHeader.created_at < last_month,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
percentage, trend = self._calculate_trend(current_total, previous_total)
|
||||
|
||||
kpi = KPIMetric(
|
||||
label="Total Facturas",
|
||||
value=current_total,
|
||||
previous_value=previous_total,
|
||||
percentage_change=percentage,
|
||||
trend=trend,
|
||||
)
|
||||
|
||||
# Facturas por mes (últimos 6 meses)
|
||||
six_months_ago = datetime.utcnow() - timedelta(days=180)
|
||||
|
||||
monthly_data = (
|
||||
self.db.query(
|
||||
func.date_trunc("month", InvoiceHeader.created_at).label("month"),
|
||||
func.count(InvoiceHeader.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
InvoiceHeader.created_at >= six_months_ago,
|
||||
)
|
||||
.group_by("month")
|
||||
.order_by("month")
|
||||
.all()
|
||||
)
|
||||
|
||||
chart_data = [
|
||||
ChartDataPoint(
|
||||
label=row.month.strftime("%b %Y") if row.month else "Unknown",
|
||||
value=float(row.count),
|
||||
)
|
||||
for row in monthly_data
|
||||
]
|
||||
|
||||
return kpi, chart_data
|
||||
|
||||
def get_client_provider_metrics(
|
||||
self,
|
||||
) -> tuple[KPIMetric, KPIMetric, List[ChartDataPoint], List[ChartDataPoint]]:
|
||||
"""Obtiene métricas de clientes y proveedores"""
|
||||
# Total clientes
|
||||
total_clients = (
|
||||
self.db.query(func.count(ClientProvider.id))
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.CLIENT, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total proveedores
|
||||
total_providers = (
|
||||
self.db.query(func.count(ClientProvider.id))
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.PROVIDER, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
client_kpi = KPIMetric(
|
||||
label="Total Clientes", value=total_clients, trend="stable"
|
||||
)
|
||||
|
||||
provider_kpi = KPIMetric(
|
||||
label="Total Proveedores", value=total_providers, trend="stable"
|
||||
)
|
||||
|
||||
# Top 5 clientes por número de facturas
|
||||
# Los clientes están en InvoiceComplianceMx.sold_to_id
|
||||
top_clients = (
|
||||
self.db.query(
|
||||
ClientProvider.name, func.count(InvoiceHeader.id).label("invoice_count")
|
||||
)
|
||||
.join(
|
||||
InvoiceComplianceMx,
|
||||
InvoiceComplianceMx.sold_to_id == ClientProvider.id,
|
||||
isouter=True,
|
||||
)
|
||||
.join(
|
||||
InvoiceHeader,
|
||||
and_(
|
||||
InvoiceHeader.id == InvoiceComplianceMx.invoice_id,
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.CLIENT, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.group_by(ClientProvider.id, ClientProvider.name)
|
||||
.order_by(desc("invoice_count"))
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
top_clients_data = [
|
||||
ChartDataPoint(
|
||||
label=row.name or f"Cliente {i+1}", value=float(row.invoice_count or 0)
|
||||
)
|
||||
for i, row in enumerate(top_clients)
|
||||
]
|
||||
|
||||
# Top 5 proveedores
|
||||
# Los proveedores están en InvoiceComplianceMx.provider_id
|
||||
top_providers = (
|
||||
self.db.query(
|
||||
ClientProvider.name, func.count(InvoiceHeader.id).label("invoice_count")
|
||||
)
|
||||
.join(
|
||||
InvoiceComplianceMx,
|
||||
InvoiceComplianceMx.provider_id == ClientProvider.id,
|
||||
isouter=True,
|
||||
)
|
||||
.join(
|
||||
InvoiceHeader,
|
||||
and_(
|
||||
InvoiceHeader.id == InvoiceComplianceMx.invoice_id,
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.PROVIDER, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.group_by(ClientProvider.id, ClientProvider.name)
|
||||
.order_by(desc("invoice_count"))
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
top_providers_data = [
|
||||
ChartDataPoint(
|
||||
label=row.name or f"Proveedor {i+1}",
|
||||
value=float(row.invoice_count or 0),
|
||||
)
|
||||
for i, row in enumerate(top_providers)
|
||||
]
|
||||
|
||||
return client_kpi, provider_kpi, top_clients_data, top_providers_data
|
||||
|
||||
def get_items_metrics(self) -> KPIMetric:
|
||||
"""Obtiene métricas de items/productos"""
|
||||
total_items = (
|
||||
self.db.query(func.count(Item.id))
|
||||
.filter(
|
||||
Item.tenant_id == self.tenant_id, Item.company_id == self.company_id
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return KPIMetric(label="Total Items", value=total_items, trend="stable")
|
||||
|
||||
def get_operations_by_type(self) -> List[ChartDataPoint]:
|
||||
"""Obtiene distribución de operaciones por tipo"""
|
||||
ops_data = (
|
||||
self.db.query(
|
||||
InvoiceHeader.operation_type,
|
||||
func.count(InvoiceHeader.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
)
|
||||
.group_by(InvoiceHeader.operation_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
type_labels = {
|
||||
OperationType.IMP: "Importación",
|
||||
OperationType.EXP: "Exportación",
|
||||
OperationType.SM_IN: "Entrada SM",
|
||||
OperationType.SM_OUT: "Salida SM",
|
||||
OperationType.CTM_SEND: "Envío CTM",
|
||||
OperationType.CTM_RECEIVE: "Recibo CTM",
|
||||
}
|
||||
|
||||
return [
|
||||
ChartDataPoint(
|
||||
label=type_labels.get(row.operation_type, str(row.operation_type)),
|
||||
value=float(row.count),
|
||||
)
|
||||
for row in ops_data
|
||||
if row.operation_type
|
||||
]
|
||||
|
||||
def get_recent_activity(self, limit: int = 10) -> List[ActivityItem]:
|
||||
"""Obtiene actividad reciente del sistema"""
|
||||
activities = []
|
||||
|
||||
# Facturas recientes
|
||||
recent_invoices = (
|
||||
self.db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
)
|
||||
.order_by(desc(InvoiceHeader.created_at))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for invoice in recent_invoices:
|
||||
activities.append(
|
||||
ActivityItem(
|
||||
id=invoice.id,
|
||||
type="invoice",
|
||||
title=f"Factura {invoice.invoice_number or invoice.id}",
|
||||
description=f"Operación: {invoice.operation_type if invoice.operation_type else 'N/A'}",
|
||||
timestamp=invoice.created_at,
|
||||
status="active",
|
||||
icon="FileText",
|
||||
)
|
||||
)
|
||||
|
||||
# Ordenar por timestamp
|
||||
activities.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
|
||||
return activities[:limit]
|
||||
|
||||
def get_pedimentos_metrics(self) -> tuple[KPIMetric, List[ChartDataPoint]]:
|
||||
"""Obtiene métricas de pedimentos"""
|
||||
# Total de pedimentos actuales
|
||||
current_total = (
|
||||
self.db.query(func.count(Pedimentos.id))
|
||||
.filter(
|
||||
Pedimentos.tenant_id == self.tenant_id,
|
||||
Pedimentos.company_id == self.company_id,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total del mes anterior para comparación
|
||||
last_month = datetime.utcnow() - timedelta(days=30)
|
||||
previous_total = (
|
||||
self.db.query(func.count(Pedimentos.id))
|
||||
.filter(
|
||||
Pedimentos.tenant_id == self.tenant_id,
|
||||
Pedimentos.company_id == self.company_id,
|
||||
Pedimentos.created_at < last_month,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
percentage, trend = self._calculate_trend(current_total, previous_total)
|
||||
|
||||
kpi = KPIMetric(
|
||||
label="Total Pedimentos",
|
||||
value=current_total,
|
||||
previous_value=previous_total,
|
||||
percentage_change=percentage,
|
||||
trend=trend,
|
||||
)
|
||||
|
||||
# Pedimentos por mes (últimos 6 meses)
|
||||
six_months_ago = datetime.utcnow() - timedelta(days=180)
|
||||
|
||||
monthly_data = (
|
||||
self.db.query(
|
||||
func.date_trunc("month", Pedimentos.created_at).label("month"),
|
||||
func.count(Pedimentos.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
Pedimentos.tenant_id == self.tenant_id,
|
||||
Pedimentos.company_id == self.company_id,
|
||||
Pedimentos.created_at >= six_months_ago,
|
||||
)
|
||||
.group_by("month")
|
||||
.order_by("month")
|
||||
.all()
|
||||
)
|
||||
|
||||
chart_data = [
|
||||
ChartDataPoint(
|
||||
label=row.month.strftime("%b %Y") if row.month else "Unknown",
|
||||
value=float(row.count),
|
||||
)
|
||||
for row in monthly_data
|
||||
]
|
||||
|
||||
return kpi, chart_data
|
||||
|
||||
def get_complete_dashboard_stats(self) -> DashboardStats:
|
||||
"""Genera estadísticas completas para el dashboard"""
|
||||
|
||||
# Obtener nombre de la compañía
|
||||
company = (
|
||||
self.db.query(Company)
|
||||
.filter(Company.id == self.company_id, Company.tenant_id == self.tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Obtener todas las métricas
|
||||
invoice_kpi, invoices_chart = self.get_invoice_metrics()
|
||||
client_kpi, provider_kpi, top_clients, top_providers = (
|
||||
self.get_client_provider_metrics()
|
||||
)
|
||||
items_kpi = self.get_items_metrics()
|
||||
operations_chart = self.get_operations_by_type()
|
||||
recent_activity = self.get_recent_activity()
|
||||
|
||||
# Obtener métricas de pedimentos
|
||||
pedimento_kpi, pedimentos_chart = self.get_pedimentos_metrics()
|
||||
|
||||
# KPI de aprobaciones pendientes
|
||||
pending_kpi = KPIMetric(label="Pendientes", value=0, trend="stable")
|
||||
|
||||
return DashboardStats(
|
||||
total_invoices=invoice_kpi,
|
||||
total_pedimentos=pedimento_kpi,
|
||||
total_clients=client_kpi,
|
||||
total_providers=provider_kpi,
|
||||
active_items=items_kpi,
|
||||
pending_approvals=pending_kpi,
|
||||
invoices_by_month=invoices_chart,
|
||||
pedimentos_by_month=pedimentos_chart,
|
||||
operations_by_type=operations_chart,
|
||||
top_clients=top_clients,
|
||||
top_providers=top_providers,
|
||||
recent_activity=recent_activity,
|
||||
company_id=self.company_id,
|
||||
company_name=company.name if company else None,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ from .auth.routes import router as auth_router
|
||||
from .licenses.routes import router as licenses_router
|
||||
from .tenants.routes import router as tenants_router
|
||||
from .user_tenant.routes import router as user_tenant_router
|
||||
from .dashboard.routes import router as dashboard_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
@@ -9,4 +10,5 @@ router = APIRouter()
|
||||
router.include_router(auth_router)
|
||||
router.include_router(tenants_router, prefix="/core", tags=["core / tenants"])
|
||||
router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"])
|
||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
||||
|
||||
@@ -64,12 +64,12 @@ export async function createUnitMeasure(data: UnitMeasureCreate): Promise<ApiRes
|
||||
* Actualiza una unidad de medida
|
||||
*/
|
||||
export async function updateUnitMeasure(id: number, data: UnitMeasureUpdate): Promise<ApiResponse<UnitMeasure>> {
|
||||
return await api.put(`/a76/units-of-measure/${id}`, data);
|
||||
return await api.put(`/a76/units-of-measure/${id}/`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una unidad de medida
|
||||
*/
|
||||
export async function deleteUnitMeasure(id: number): Promise<ApiResponse<void>> {
|
||||
return await api.delete(`/a76/units-of-measure/${id}`);
|
||||
return await api.delete(`/a76/units-of-measure/${id}/`);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const unitsOfMeasureApi = {
|
||||
page_size: pageSize.toString()
|
||||
});
|
||||
return api.get<UnitOfMeasureListResponse>(
|
||||
`/v1/a76/units-of-measure?${params.toString()}`
|
||||
`/v1/a76/units-of-measure/?${params.toString()}`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ export const unitsOfMeasureApi = {
|
||||
* @param data - Datos de la unidad de medida a crear
|
||||
*/
|
||||
create: (companyId: number, data: CreateUnitOfMeasureData) =>
|
||||
api.post<UnitOfMeasure>(`/v1/a76/units-of-measure?company_id=${companyId}`, data),
|
||||
api.post<UnitOfMeasure>(`/v1/a76/units-of-measure/?company_id=${companyId}`, data),
|
||||
|
||||
/**
|
||||
* Actualiza una unidad de medida existente
|
||||
@@ -87,7 +87,7 @@ export const unitsOfMeasureApi = {
|
||||
*/
|
||||
update: (companyId: number, id: number, data: UpdateUnitOfMeasureData) =>
|
||||
api.put<UnitOfMeasure>(
|
||||
`/v1/a76/units-of-measure/${id}?company_id=${companyId}`,
|
||||
`/v1/a76/units-of-measure/${id}/?company_id=${companyId}`,
|
||||
data
|
||||
),
|
||||
|
||||
@@ -97,5 +97,5 @@ export const unitsOfMeasureApi = {
|
||||
* @param id - ID de la unidad de medida a eliminar
|
||||
*/
|
||||
delete: (companyId: number, id: number) =>
|
||||
api.delete(`/v1/a76/units-of-measure/${id}?company_id=${companyId}`)
|
||||
api.delete(`/v1/a76/units-of-measure/${id}/?company_id=${companyId}`)
|
||||
};
|
||||
|
||||
42
frontend/src/lib/api/dashboard/index.ts
Normal file
42
frontend/src/lib/api/dashboard/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Cliente API para el dashboard
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import type { DashboardStats, OperationsOverview, InventoryMetrics } from './types';
|
||||
|
||||
export async function getDashboardStats(companyId: number): Promise<DashboardStats> {
|
||||
const response = await api.get<DashboardStats>(
|
||||
`/v1/core/dashboard/stats?company_id=${companyId}`
|
||||
);
|
||||
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Failed to fetch dashboard stats');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getOperationsOverview(companyId: number): Promise<OperationsOverview> {
|
||||
const response = await api.get<OperationsOverview>(
|
||||
`/v1/core/dashboard/operations-overview?company_id=${companyId}`
|
||||
);
|
||||
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Failed to fetch operations overview');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getInventoryMetrics(companyId: number): Promise<InventoryMetrics> {
|
||||
const response = await api.get<InventoryMetrics>(
|
||||
`/v1/core/dashboard/inventory-metrics?company_id=${companyId}`
|
||||
);
|
||||
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Failed to fetch inventory metrics');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
62
frontend/src/lib/api/dashboard/types.ts
Normal file
62
frontend/src/lib/api/dashboard/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Tipos TypeScript para el dashboard
|
||||
*/
|
||||
|
||||
export interface KPIMetric {
|
||||
label: string;
|
||||
value: number;
|
||||
previous_value?: number;
|
||||
percentage_change?: number;
|
||||
trend?: 'up' | 'down' | 'stable';
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
status?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ChartDataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_invoices: KPIMetric;
|
||||
total_pedimentos: KPIMetric;
|
||||
total_clients: KPIMetric;
|
||||
total_providers: KPIMetric;
|
||||
active_items: KPIMetric;
|
||||
pending_approvals: KPIMetric;
|
||||
total_value_imports?: number;
|
||||
total_value_exports?: number;
|
||||
invoices_by_month: ChartDataPoint[];
|
||||
operations_by_type: ChartDataPoint[];
|
||||
top_clients: ChartDataPoint[];
|
||||
top_providers: ChartDataPoint[];
|
||||
recent_activity: ActivityItem[];
|
||||
generated_at: string;
|
||||
company_id: number;
|
||||
company_name?: string;
|
||||
}
|
||||
|
||||
export interface OperationsOverview {
|
||||
total_operations: number;
|
||||
by_type: Record<string, number>;
|
||||
by_status: Record<string, number>;
|
||||
avg_processing_time?: number;
|
||||
}
|
||||
|
||||
export interface InventoryMetrics {
|
||||
total_items: number;
|
||||
items_in_stock: number;
|
||||
items_low_stock: number;
|
||||
total_value?: number;
|
||||
by_category: Record<string, number>;
|
||||
}
|
||||
106
frontend/src/lib/components/dashboard/activity-feed.svelte
Normal file
106
frontend/src/lib/components/dashboard/activity-feed.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import type { ActivityItem } from '$lib/api/dashboard/types';
|
||||
import {
|
||||
FileText,
|
||||
Users,
|
||||
Package,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
TruckIcon
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
activities: ActivityItem[];
|
||||
}
|
||||
|
||||
let { activities }: Props = $props();
|
||||
|
||||
const icons = {
|
||||
invoice: FileText,
|
||||
pedimento: FileText,
|
||||
client: Users,
|
||||
provider: Users,
|
||||
item: Package,
|
||||
transport: TruckIcon
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
active: 'default',
|
||||
pending: 'secondary',
|
||||
completed: 'outline',
|
||||
error: 'destructive'
|
||||
} as const;
|
||||
|
||||
function getIcon(type: string) {
|
||||
return icons[type as keyof typeof icons] || FileText;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `Hace ${diffMins} min`;
|
||||
} else if (diffHours < 24) {
|
||||
return `Hace ${diffHours}h`;
|
||||
} else if (diffDays < 7) {
|
||||
return `Hace ${diffDays}d`;
|
||||
} else {
|
||||
return date.toLocaleDateString('es-MX', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Actividad Reciente</Card.Title>
|
||||
<Card.Description>Últimas operaciones registradas en el sistema</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if activities.length === 0}
|
||||
<div class="text-center text-muted-foreground py-8">
|
||||
<Clock class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No hay actividad reciente</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each activities as activity}
|
||||
{@const Icon = getIcon(activity.type)}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="mt-1">
|
||||
<div class="p-2 rounded-lg bg-primary/10">
|
||||
<Icon class="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium leading-none">{activity.title}</p>
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(activity.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{#if activity.description}
|
||||
<p class="text-sm text-muted-foreground mt-1">{activity.description}</p>
|
||||
{/if}
|
||||
{#if activity.status}
|
||||
<Badge
|
||||
variant={statusColors[activity.status as keyof typeof statusColors] || 'default'}
|
||||
class="mt-2"
|
||||
>
|
||||
{activity.status}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
59
frontend/src/lib/components/dashboard/chart-card.svelte
Normal file
59
frontend/src/lib/components/dashboard/chart-card.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import type { ChartDataPoint } from '$lib/api/dashboard/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
data: ChartDataPoint[];
|
||||
type?: 'bar' | 'line' | 'pie';
|
||||
}
|
||||
|
||||
let { title, description, data, type = 'bar' }: Props = $props();
|
||||
|
||||
let maxValue = $derived(Math.max(...data.map((d) => d.value), 1));
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{title}</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description>{description}</Card.Description>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.length === 0}
|
||||
<div class="text-center text-muted-foreground py-8">No hay datos disponibles</div>
|
||||
{:else if type === 'bar'}
|
||||
<div class="space-y-3">
|
||||
{#each data as item}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium truncate">{item.label}</span>
|
||||
<span class="text-muted-foreground">{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style="width: {(item.value / maxValue) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === 'pie'}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{#each data as item}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-primary"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{item.label}</div>
|
||||
<div class="text-xs text-muted-foreground">{item.value.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
104
frontend/src/lib/components/dashboard/donut-chart.svelte
Normal file
104
frontend/src/lib/components/dashboard/donut-chart.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import type { ChartDataPoint } from '$lib/api/dashboard/types';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
data: ChartDataPoint[];
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
let { title, data, colors = ['hsl(var(--primary))', 'hsl(var(--secondary))', 'hsl(var(--accent))', 'hsl(var(--muted))'] }: Props = $props();
|
||||
|
||||
let total = $derived(data.reduce((sum, d) => sum + d.value, 0));
|
||||
|
||||
let segments = $derived(
|
||||
data.map((d, i) => ({
|
||||
...d,
|
||||
percentage: total > 0 ? (d.value / total) * 100 : 0,
|
||||
color: colors[i % colors.length]
|
||||
}))
|
||||
);
|
||||
|
||||
// Generate SVG donut chart
|
||||
let radius = 80;
|
||||
let strokeWidth = 30;
|
||||
let circumference = 2 * Math.PI * radius;
|
||||
|
||||
let donutSegments = $derived(() => {
|
||||
let cumulativePercentage = 0;
|
||||
return segments.map((seg) => {
|
||||
const offset = (cumulativePercentage / 100) * circumference;
|
||||
const dashArray = `${(seg.percentage / 100) * circumference} ${circumference}`;
|
||||
cumulativePercentage += seg.percentage;
|
||||
return {
|
||||
...seg,
|
||||
offset,
|
||||
dashArray
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{title}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.length === 0}
|
||||
<div class="text-center text-muted-foreground py-8">No hay datos disponibles</div>
|
||||
{:else}
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-8">
|
||||
<!-- Donut Chart -->
|
||||
<div class="relative">
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" class="transform -rotate-90">
|
||||
{#each donutSegments() as segment, i}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={segment.color}
|
||||
stroke-width={strokeWidth}
|
||||
stroke-dasharray={segment.dashArray}
|
||||
stroke-dashoffset={-segment.offset}
|
||||
class="transition-all duration-500"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
<!-- Center Text -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold">{total.toLocaleString()}</div>
|
||||
<div class="text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="space-y-2 flex-1">
|
||||
{#each segments as segment, i}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style="background-color: {segment.color}"
|
||||
></div>
|
||||
<span class="text-sm font-medium truncate">{segment.label}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{segment.value.toLocaleString()}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground w-12 text-right">
|
||||
({segment.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
56
frontend/src/lib/components/dashboard/kpi-card.svelte
Normal file
56
frontend/src/lib/components/dashboard/kpi-card.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-svelte';
|
||||
import type { KPIMetric } from '$lib/api/dashboard/types';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
metric: KPIMetric;
|
||||
icon?: any;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
let { metric, icon: Icon, iconColor = 'text-primary' }: Props = $props();
|
||||
|
||||
const trendIcons = {
|
||||
up: TrendingUp,
|
||||
down: TrendingDown,
|
||||
stable: Minus
|
||||
};
|
||||
|
||||
const trendColors = {
|
||||
up: 'text-green-600',
|
||||
down: 'text-red-600',
|
||||
stable: 'text-gray-600'
|
||||
};
|
||||
|
||||
const TrendIcon = metric.trend ? trendIcons[metric.trend] : null;
|
||||
</script>
|
||||
|
||||
<Card.Root class="overflow-hidden">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium text-muted-foreground">
|
||||
{metric.label}
|
||||
</Card.Title>
|
||||
{#if Icon}
|
||||
<Icon class={cn('h-4 w-4', iconColor)} />
|
||||
{/if}
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">
|
||||
{metric.value.toLocaleString()}
|
||||
{#if metric.unit}
|
||||
<span class="text-sm font-normal text-muted-foreground ml-1">{metric.unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if metric.percentage_change !== undefined && TrendIcon}
|
||||
<div class="flex items-center text-xs mt-1">
|
||||
<TrendIcon class={cn('h-3 w-3 mr-1', trendColors[metric.trend || 'stable'])} />
|
||||
<span class={cn(trendColors[metric.trend || 'stable'])}>
|
||||
{Math.abs(metric.percentage_change).toFixed(1)}%
|
||||
</span>
|
||||
<span class="text-muted-foreground ml-1">vs mes anterior</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
73
frontend/src/lib/components/dashboard/trend-chart.svelte
Normal file
73
frontend/src/lib/components/dashboard/trend-chart.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
import type { ChartDataPoint } from '$lib/api/dashboard/types';
|
||||
|
||||
interface Props {
|
||||
monthlyData: ChartDataPoint[];
|
||||
}
|
||||
|
||||
let { monthlyData }: Props = $props();
|
||||
|
||||
let maxValue = $derived(Math.max(...monthlyData.map((d) => d.value), 1));
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Tendencia de Operaciones</Card.Title>
|
||||
<Card.Description>Evolución mensual de facturas y pedimentos</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if monthlyData.length === 0}
|
||||
<div class="text-center text-muted-foreground py-12">
|
||||
<p>No hay datos disponibles</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Line Chart Visualization -->
|
||||
<div class="h-64 flex items-end justify-between gap-2 border-b border-l p-4">
|
||||
{#each monthlyData as point, i}
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<!-- Bar -->
|
||||
<div class="w-full flex items-end justify-center" style="height: 200px;">
|
||||
<div
|
||||
class="w-full max-w-16 bg-primary rounded-t-md transition-all duration-500 hover:bg-primary/80 relative group-hover:shadow-lg"
|
||||
style="height: {(point.value / maxValue) * 100}%"
|
||||
>
|
||||
<!-- Tooltip on hover -->
|
||||
<div
|
||||
class="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-popover text-popover-foreground px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap shadow-md border"
|
||||
>
|
||||
{point.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<span class="text-xs text-muted-foreground font-medium">{point.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="grid grid-cols-3 gap-4 mt-4 pt-4 border-t">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{monthlyData.reduce((sum, d) => sum + d.value, 0).toLocaleString()}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{Math.round(
|
||||
monthlyData.reduce((sum, d) => sum + d.value, 0) / monthlyData.length
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Promedio</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">{maxValue.toLocaleString()}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Máximo</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -1,79 +1,217 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { FileText, LayoutGrid, Package } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
|
||||
import ChartCard from '$lib/components/dashboard/chart-card.svelte';
|
||||
import ActivityFeed from '$lib/components/dashboard/activity-feed.svelte';
|
||||
import TrendChart from '$lib/components/dashboard/trend-chart.svelte';
|
||||
import DonutChart from '$lib/components/dashboard/donut-chart.svelte';
|
||||
import { getDashboardStats } from '$lib/api/dashboard';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
FileText,
|
||||
Users,
|
||||
Package,
|
||||
TruckIcon,
|
||||
Clock,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
import type { DashboardStats } from '$lib/api/dashboard/types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
|
||||
let stats = $state<DashboardStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function loadDashboardData() {
|
||||
if (!companyStore.activeCompany) {
|
||||
error = 'No hay compañía activa seleccionada';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
stats = await getDashboardStats(companyStore.activeCompany.id);
|
||||
} catch (err) {
|
||||
console.error('Error loading dashboard:', err);
|
||||
error = err instanceof Error ? err.message : 'Error al cargar el dashboard';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reaccionar cuando cambie la compañía activa
|
||||
$effect(() => {
|
||||
if (companyStore.activeCompany) {
|
||||
loadDashboardData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Welcome Section -->
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Bienvenido al Dashboard</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Sistema de gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm font-medium">Total de Pedimentos</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">0</div>
|
||||
<p class="text-xs text-muted-foreground">Registros activos</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm font-medium">Datos de Referencia</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">12</div>
|
||||
<p class="text-xs text-muted-foreground">Catálogos disponibles</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm font-medium">Licencia Activa</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">✓</div>
|
||||
<p class="text-xs text-muted-foreground">Cuenta verificada</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Accesos Rápidos</Card.Title>
|
||||
<Card.Description>Accede a las funciones más utilizadas del sistema</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
href="/dashboard/reference_data/code_pedimento_regimens"
|
||||
class="flex flex-col items-center justify-center rounded-lg border p-6 hover:bg-accent transition-colors"
|
||||
>
|
||||
<FileText class="mb-2" size={24} />
|
||||
<span class="font-medium">Código Pedimento - Regímenes</span>
|
||||
<span class="text-xs text-muted-foreground">Gestionar relaciones</span>
|
||||
</a> <div
|
||||
class="flex flex-col items-center justify-center rounded-lg border p-6 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<LayoutGrid class="mb-2" size={24} />
|
||||
<span class="font-medium">Catálogo de Tipos</span>
|
||||
<span class="text-xs text-muted-foreground">Próximamente</span>
|
||||
</div> <div
|
||||
class="flex flex-col items-center justify-center rounded-lg border p-6 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<Package class="mb-2" size={24} />
|
||||
<span class="font-medium">Reportes</span>
|
||||
<span class="text-xs text-muted-foreground">Próximamente</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{#if stats?.company_name}
|
||||
{stats.company_name} - Sistema de gestión de comercio exterior
|
||||
{:else}
|
||||
Sistema de gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
|
||||
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<Alert.Title>Error</Alert.Title>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(6) as _}
|
||||
<Card.Root class="animate-pulse">
|
||||
<Card.Header class="space-y-2">
|
||||
<div class="h-4 w-24 bg-muted rounded"></div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="h-8 w-16 bg-muted rounded"></div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stats}
|
||||
<!-- KPIs Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<KpiCard metric={stats.total_invoices} icon={FileText} iconColor="text-blue-600" />
|
||||
<KpiCard metric={stats.total_pedimentos} icon={FileText} iconColor="text-green-600" />
|
||||
<KpiCard metric={stats.total_clients} icon={Users} iconColor="text-purple-600" />
|
||||
<KpiCard metric={stats.total_providers} icon={Users} iconColor="text-orange-600" />
|
||||
<KpiCard metric={stats.active_items} icon={Package} iconColor="text-cyan-600" />
|
||||
<KpiCard metric={stats.pending_approvals} icon={Clock} iconColor="text-yellow-600" />
|
||||
</div>
|
||||
|
||||
<!-- Main Charts -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<TrendChart monthlyData={stats.invoices_by_month} />
|
||||
<DonutChart title="Distribución de Operaciones" data={stats.operations_by_type} />
|
||||
</div>
|
||||
|
||||
<!-- Analytics Row -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<ChartCard
|
||||
title="Top 5 Clientes"
|
||||
description="Por número de facturas"
|
||||
data={stats.top_clients}
|
||||
type="bar"
|
||||
/>
|
||||
<ChartCard
|
||||
title="Top 5 Proveedores"
|
||||
description="Por número de facturas"
|
||||
data={stats.top_providers}
|
||||
type="bar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Activity Feed & Quick Actions -->
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<ActivityFeed activities={stats.recent_activity} />
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Accesos Rápidos</Card.Title>
|
||||
<Card.Description>Funciones más utilizadas</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-2">
|
||||
<a
|
||||
href="/dashboard/invoices"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent transition-colors"
|
||||
>
|
||||
<FileText class="h-5 w-5 text-primary" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium">Facturas</div>
|
||||
<div class="text-xs text-muted-foreground">Gestionar facturas</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard/clients_and_providers"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent transition-colors"
|
||||
>
|
||||
<Users class="h-5 w-5 text-primary" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium">Clientes y Proveedores</div>
|
||||
<div class="text-xs text-muted-foreground">Administrar contactos</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard/goods"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent transition-colors"
|
||||
>
|
||||
<Package class="h-5 w-5 text-primary" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium">Mercancías</div>
|
||||
<div class="text-xs text-muted-foreground">Catálogo de productos</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard/reference_data/code_pedimento_regimens"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent transition-colors"
|
||||
>
|
||||
<BarChart3 class="h-5 w-5 text-primary" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium">Datos de Referencia</div>
|
||||
<div class="text-xs text-muted-foreground">Catálogos del SAT</div>
|
||||
</div>
|
||||
</a>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<Card.Root class="bg-muted/50">
|
||||
<Card.Content class="pt-6">
|
||||
<div class="grid gap-4 md:grid-cols-3 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{stats.total_invoices.value + stats.total_pedimentos.value}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total de Documentos</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{stats.total_clients.value + stats.total_providers.value}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total de Contactos</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-primary">{stats.active_items.value}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Items Activos</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user