Merge pull request 'feature/dashboard' (#53) from feature/dashboard into development

Reviewed-on: ADUANASOFT/anexo76#53
This commit is contained in:
2026-01-13 14:23:25 +00:00
15 changed files with 1349 additions and 79 deletions

View File

@@ -0,0 +1,7 @@
"""
Módulo de dashboard para estadísticas y métricas empresariales
"""
from .routes import router
__all__ = ["router"]

View 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"
)

View 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={},
)

View 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,
)

View File

@@ -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"])

View File

@@ -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}/`);
}

View File

@@ -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}`)
};

View 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;
}

View 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>;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>