feat(alertas): add alerts CRUD with stats and management UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-22 03:00:14 +00:00
parent 6d59c8d842
commit 9b8aaea7eb
31 changed files with 4892 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas';
import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
const prioridadStyles = {
alta: 'border-l-4 border-l-destructive bg-destructive/5',
media: 'border-l-4 border-l-warning bg-warning/5',
baja: 'border-l-4 border-l-muted bg-muted/5',
};
const prioridadIcons = {
alta: AlertCircle,
media: AlertTriangle,
baja: Info,
};
export default function AlertasPage() {
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes');
const { data: alertas, isLoading } = useAlertas({
resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined,
});
const { data: stats } = useAlertasStats();
const updateAlerta = useUpdateAlerta();
const deleteAlerta = useDeleteAlerta();
const markAllAsRead = useMarkAllAsRead();
const handleMarkAsRead = (id: number) => {
updateAlerta.mutate({ id, data: { leida: true } });
};
const handleResolve = (id: number) => {
updateAlerta.mutate({ id, data: { resuelta: true } });
};
const handleDelete = (id: number) => {
if (confirm('¿Eliminar esta alerta?')) {
deleteAlerta.mutate(id);
}
};
return (
<DashboardShell
title="Alertas"
description="Gestiona tus alertas y notificaciones"
>
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total</CardTitle>
<Bell className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">No Leidas</CardTitle>
<AlertCircle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{stats?.noLeidas || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Alta Prioridad</CardTitle>
<AlertTriangle className="h-4 w-4 text-warning" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-warning">{stats?.alta || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Pendientes</CardTitle>
<Info className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(stats?.alta || 0) + (stats?.media || 0) + (stats?.baja || 0)}</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex gap-2">
<Button
variant={filter === 'todas' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('todas')}
>
Todas
</Button>
<Button
variant={filter === 'pendientes' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('pendientes')}
>
Pendientes
</Button>
<Button
variant={filter === 'resueltas' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('resueltas')}
>
Resueltas
</Button>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={() => markAllAsRead.mutate()}
disabled={markAllAsRead.isPending}
>
Marcar todas como leidas
</Button>
</div>
{/* Alertas List */}
<div className="space-y-2">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : alertas?.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<CheckCircle className="h-12 w-12 mx-auto mb-4 text-success" />
<p>No hay alertas {filter === 'pendientes' ? 'pendientes' : ''}</p>
</CardContent>
</Card>
) : (
alertas?.map((alerta) => {
const Icon = prioridadIcons[alerta.prioridad];
return (
<Card key={alerta.id} className={cn(prioridadStyles[alerta.prioridad], alerta.leida && 'opacity-60')}>
<CardContent className="py-4">
<div className="flex items-start gap-4">
<Icon className={cn(
'h-5 w-5 mt-0.5',
alerta.prioridad === 'alta' && 'text-destructive',
alerta.prioridad === 'media' && 'text-warning',
alerta.prioridad === 'baja' && 'text-muted-foreground'
)} />
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{alerta.titulo}</h4>
{!alerta.leida && (
<span className="px-2 py-0.5 text-xs bg-primary text-primary-foreground rounded-full">
Nueva
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">{alerta.mensaje}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span>{new Date(alerta.createdAt).toLocaleDateString('es-MX')}</span>
{alerta.fechaVencimiento && (
<span>Vence: {new Date(alerta.fechaVencimiento).toLocaleDateString('es-MX')}</span>
)}
</div>
</div>
<div className="flex gap-1">
{!alerta.leida && (
<Button
variant="ghost"
size="icon"
onClick={() => handleMarkAsRead(alerta.id)}
title="Marcar como leida"
>
<Check className="h-4 w-4" />
</Button>
)}
{!alerta.resuelta && (
<Button
variant="ghost"
size="icon"
onClick={() => handleResolve(alerta.id)}
title="Marcar como resuelta"
>
<CheckCircle className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(alerta.id)}
title="Eliminar"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useEventos, useUpdateEvento } from '@/lib/hooks/use-calendario';
import { Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText, CreditCard } from 'lucide-react';
import { cn } from '@/lib/utils';
const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const tipoIcons = {
declaracion: FileText,
pago: CreditCard,
obligacion: Clock,
custom: Calendar,
};
const tipoColors = {
declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
custom: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
};
export default function CalendarioPage() {
const [año, setAño] = useState(new Date().getFullYear());
const [mes, setMes] = useState(new Date().getMonth() + 1);
const { data: eventos, isLoading } = useEventos(año, mes);
const updateEvento = useUpdateEvento();
const handlePrevMonth = () => {
if (mes === 1) {
setMes(12);
setAño(año - 1);
} else {
setMes(mes - 1);
}
};
const handleNextMonth = () => {
if (mes === 12) {
setMes(1);
setAño(año + 1);
} else {
setMes(mes + 1);
}
};
const handleToggleComplete = (id: number, completado: boolean) => {
updateEvento.mutate({ id, data: { completado: !completado } });
};
// Generate calendar days
const firstDay = new Date(año, mes - 1, 1).getDay();
const daysInMonth = new Date(año, mes, 0).getDate();
const days = Array.from({ length: 42 }, (_, i) => {
const day = i - firstDay + 1;
if (day < 1 || day > daysInMonth) return null;
return day;
});
const getEventosForDay = (day: number) => {
return eventos?.filter(e => {
const fecha = new Date(e.fechaLimite);
return fecha.getDate() === day;
}) || [];
};
return (
<DashboardShell
title="Calendario Fiscal"
description="Obligaciones fiscales y eventos importantes"
>
<div className="grid gap-4 lg:grid-cols-3">
{/* Calendar */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{meses[mes - 1]} {año}
</CardTitle>
<div className="flex gap-1">
<Button variant="outline" size="icon" onClick={handlePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-7 gap-1">
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => (
<div key={d} className="text-center text-sm font-medium text-muted-foreground py-2">
{d}
</div>
))}
{days.map((day, i) => {
const dayEventos = day ? getEventosForDay(day) : [];
const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear();
return (
<div
key={i}
className={cn(
'min-h-[80px] p-1 border rounded-md',
day ? 'bg-background' : 'bg-muted/30',
isToday && 'ring-2 ring-primary'
)}
>
{day && (
<>
<div className={cn('text-sm font-medium', isToday && 'text-primary')}>{day}</div>
<div className="space-y-1 mt-1">
{dayEventos.slice(0, 2).map(e => {
const Icon = tipoIcons[e.tipo];
return (
<div
key={e.id}
className={cn(
'text-xs px-1 py-0.5 rounded truncate flex items-center gap-1',
tipoColors[e.tipo],
e.completado && 'opacity-50 line-through'
)}
title={e.titulo}
>
<Icon className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{e.titulo}</span>
</div>
);
})}
{dayEventos.length > 2 && (
<div className="text-xs text-muted-foreground">+{dayEventos.length - 2} más</div>
)}
</div>
</>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Event List */}
<Card>
<CardHeader>
<CardTitle>Eventos del Mes</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-4 text-muted-foreground">Cargando...</div>
) : eventos?.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">No hay eventos este mes</div>
) : (
<div className="space-y-3">
{eventos?.map(evento => {
const Icon = tipoIcons[evento.tipo];
return (
<div
key={evento.id}
className={cn(
'p-3 rounded-lg border',
evento.completado && 'opacity-50'
)}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-2">
<div className={cn('p-1.5 rounded', tipoColors[evento.tipo])}>
<Icon className="h-4 w-4" />
</div>
<div>
<h4 className={cn('font-medium text-sm', evento.completado && 'line-through')}>
{evento.titulo}
</h4>
<p className="text-xs text-muted-foreground mt-0.5">{evento.descripcion}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(evento.fechaLimite).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
})}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleToggleComplete(evento.id, evento.completado)}
>
<Check className={cn('h-4 w-4', evento.completado && 'text-success')} />
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes';
import { BarChart } from '@/components/charts/bar-chart';
import { formatCurrency } from '@/lib/utils';
import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react';
export default function ReportesPage() {
const [año] = useState(new Date().getFullYear());
const fechaInicio = `${año}-01-01`;
const fechaFin = `${año}-12-31`;
const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin);
const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin);
const { data: comparativo, isLoading: loadingComp } = useComparativo(año);
const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
return (
<DashboardShell
title="Reportes"
description="Analisis financiero y reportes fiscales"
>
<Tabs defaultValue="estado-resultados" className="space-y-4">
<TabsList>
<TabsTrigger value="estado-resultados">Estado de Resultados</TabsTrigger>
<TabsTrigger value="flujo-efectivo">Flujo de Efectivo</TabsTrigger>
<TabsTrigger value="comparativo">Comparativo</TabsTrigger>
<TabsTrigger value="concentrado">Concentrado RFC</TabsTrigger>
</TabsList>
<TabsContent value="estado-resultados" className="space-y-4">
{loadingER ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : estadoResultados ? (
<>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Ingresos</CardTitle>
<TrendingUp className="h-4 w-4 text-success" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">
{formatCurrency(estadoResultados.totalIngresos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Egresos</CardTitle>
<TrendingDown className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{formatCurrency(estadoResultados.totalEgresos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Utilidad Bruta</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${estadoResultados.utilidadBruta >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(estadoResultados.utilidadBruta)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Utilidad Neta</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${estadoResultados.utilidadNeta >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(estadoResultados.utilidadNeta)}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top 10 Ingresos por Cliente</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{estadoResultados.ingresos.map((item, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
<span className="font-medium">{formatCurrency(item.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top 10 Egresos por Proveedor</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{estadoResultados.egresos.map((item, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
<span className="font-medium">{formatCurrency(item.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</>
) : null}
</TabsContent>
<TabsContent value="flujo-efectivo" className="space-y-4">
{loadingFE ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : flujoEfectivo ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Entradas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">
{formatCurrency(flujoEfectivo.totalEntradas)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Salidas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{formatCurrency(flujoEfectivo.totalSalidas)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Flujo Neto</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${flujoEfectivo.flujoNeto >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(flujoEfectivo.flujoNeto)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Flujo de Efectivo Mensual</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={flujoEfectivo.entradas.map((e, i) => ({
mes: e.concepto,
ingresos: e.monto,
egresos: flujoEfectivo.salidas[i]?.monto || 0,
}))}
/>
</CardContent>
</Card>
</>
) : null}
</TabsContent>
<TabsContent value="comparativo" className="space-y-4">
{loadingComp ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : comparativo ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Var. Ingresos vs Ano Anterior</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${comparativo.variacionIngresos >= 0 ? 'text-success' : 'text-destructive'}`}>
{comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Var. Egresos vs Ano Anterior</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${comparativo.variacionEgresos <= 0 ? 'text-success' : 'text-destructive'}`}>
{comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Ano Actual</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{año}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Comparativo Mensual {año}</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={comparativo.periodos.map((mes, i) => ({
mes,
ingresos: comparativo.ingresos[i],
egresos: comparativo.egresos[i],
}))}
/>
</CardContent>
</Card>
</>
) : null}
</TabsContent>
<TabsContent value="concentrado" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Clientes
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{clientes?.slice(0, 10).map((c, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{c.nombre}</div>
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
</div>
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Proveedores
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{proveedores?.slice(0, 10).map((p, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{p.nombre}</div>
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
</div>
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</DashboardShell>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator } from 'lucide-react';
import { cn } from '@/lib/utils';
const roleLabels = {
admin: { label: 'Administrador', icon: Shield, color: 'text-primary' },
contador: { label: 'Contador', icon: Calculator, color: 'text-success' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
export default function UsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading } = useUsuarios();
const inviteUsuario = useInviteUsuario();
const updateUsuario = useUpdateUsuario();
const deleteUsuario = useDeleteUsuario();
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const });
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
try {
await inviteUsuario.mutateAsync(inviteForm);
setShowInvite(false);
setInviteForm({ email: '', nombre: '', role: 'visor' });
} catch (error: any) {
alert(error.response?.data?.message || 'Error al invitar usuario');
}
};
const handleToggleActive = (id: string, active: boolean) => {
updateUsuario.mutate({ id, data: { active: !active } });
};
const handleDelete = (id: string) => {
if (confirm('¿Eliminar este usuario?')) {
deleteUsuario.mutate(id);
}
};
const isAdmin = currentUser?.role === 'admin';
return (
<DashboardShell
title="Usuarios"
description="Gestiona los usuarios de tu empresa"
>
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span className="font-medium">{usuarios?.length || 0} usuarios</span>
</div>
{isAdmin && (
<Button onClick={() => setShowInvite(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</Button>
)}
</div>
{/* Invite Form */}
{showInvite && (
<Card>
<CardHeader>
<CardTitle>Invitar Nuevo Usuario</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="nombre">Nombre</Label>
<Input
id="nombre"
value={inviteForm.nombre}
onChange={e => setInviteForm({ ...inviteForm, nombre: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v: 'admin' | 'contador' | 'visor') => setInviteForm({ ...inviteForm, role: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrador</SelectItem>
<SelectItem value="contador">Contador</SelectItem>
<SelectItem value="visor">Visor</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={inviteUsuario.isPending}>
{inviteUsuario.isPending ? 'Enviando...' : 'Enviar Invitación'}
</Button>
<Button type="button" variant="outline" onClick={() => setShowInvite(false)}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Users List */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="divide-y">
{usuarios?.map(usuario => {
const roleInfo = roleLabels[usuario.role];
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"></span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
{isAdmin && !isCurrentUser && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActive(usuario.id, usuario.active)}
>
{usuario.active ? 'Desactivar' : 'Activar'}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,33 @@
import { apiClient } from './client';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
export async function getAlertas(filters?: { leida?: boolean; resuelta?: boolean }): Promise<AlertaFull[]> {
const params = new URLSearchParams();
if (filters?.leida !== undefined) params.set('leida', String(filters.leida));
if (filters?.resuelta !== undefined) params.set('resuelta', String(filters.resuelta));
const response = await apiClient.get<AlertaFull[]>(`/alertas?${params}`);
return response.data;
}
export async function getStats(): Promise<AlertasStats> {
const response = await apiClient.get<AlertasStats>('/alertas/stats');
return response.data;
}
export async function createAlerta(data: AlertaCreate): Promise<AlertaFull> {
const response = await apiClient.post<AlertaFull>('/alertas', data);
return response.data;
}
export async function updateAlerta(id: number, data: AlertaUpdate): Promise<AlertaFull> {
const response = await apiClient.patch<AlertaFull>(`/alertas/${id}`, data);
return response.data;
}
export async function deleteAlerta(id: number): Promise<void> {
await apiClient.delete(`/alertas/${id}`);
}
export async function markAllAsRead(): Promise<void> {
await apiClient.post('/alertas/mark-all-read');
}

View File

@@ -0,0 +1,28 @@
import { apiClient } from './client';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export async function getEventos(año: number, mes?: number): Promise<EventoFiscal[]> {
const params = new URLSearchParams({ año: año.toString() });
if (mes) params.set('mes', mes.toString());
const response = await apiClient.get<EventoFiscal[]>(`/calendario?${params}`);
return response.data;
}
export async function getProximos(dias = 30): Promise<EventoFiscal[]> {
const response = await apiClient.get<EventoFiscal[]>(`/calendario/proximos?dias=${dias}`);
return response.data;
}
export async function createEvento(data: EventoCreate): Promise<EventoFiscal> {
const response = await apiClient.post<EventoFiscal>('/calendario', data);
return response.data;
}
export async function updateEvento(id: number, data: EventoUpdate): Promise<EventoFiscal> {
const response = await apiClient.patch<EventoFiscal>(`/calendario/${id}`, data);
return response.data;
}
export async function deleteEvento(id: number): Promise<void> {
await apiClient.delete(`/calendario/${id}`);
}

View File

@@ -0,0 +1,36 @@
import { apiClient } from './client';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string): Promise<EstadoResultados> {
const params = new URLSearchParams();
if (fechaInicio) params.set('fechaInicio', fechaInicio);
if (fechaFin) params.set('fechaFin', fechaFin);
const response = await apiClient.get<EstadoResultados>(`/reportes/estado-resultados?${params}`);
return response.data;
}
export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string): Promise<FlujoEfectivo> {
const params = new URLSearchParams();
if (fechaInicio) params.set('fechaInicio', fechaInicio);
if (fechaFin) params.set('fechaFin', fechaFin);
const response = await apiClient.get<FlujoEfectivo>(`/reportes/flujo-efectivo?${params}`);
return response.data;
}
export async function getComparativo(año?: number): Promise<ComparativoPeriodos> {
const params = año ? `?año=${año}` : '';
const response = await apiClient.get<ComparativoPeriodos>(`/reportes/comparativo${params}`);
return response.data;
}
export async function getConcentradoRfc(
tipo: 'cliente' | 'proveedor',
fechaInicio?: string,
fechaFin?: string
): Promise<ConcentradoRfc[]> {
const params = new URLSearchParams({ tipo });
if (fechaInicio) params.set('fechaInicio', fechaInicio);
if (fechaFin) params.set('fechaFin', fechaFin);
const response = await apiClient.get<ConcentradoRfc[]>(`/reportes/concentrado-rfc?${params}`);
return response.data;
}

View File

@@ -0,0 +1,21 @@
import { apiClient } from './client';
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
export async function getUsuarios(): Promise<UserListItem[]> {
const response = await apiClient.get<UserListItem[]>('/usuarios');
return response.data;
}
export async function inviteUsuario(data: UserInvite): Promise<UserListItem> {
const response = await apiClient.post<UserListItem>('/usuarios/invite', data);
return response.data;
}
export async function updateUsuario(id: string, data: UserUpdate): Promise<UserListItem> {
const response = await apiClient.patch<UserListItem>(`/usuarios/${id}`, data);
return response.data;
}
export async function deleteUsuario(id: string): Promise<void> {
await apiClient.delete(`/usuarios/${id}`);
}

View File

@@ -0,0 +1,61 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as alertasApi from '../api/alertas';
import type { AlertaCreate, AlertaUpdate } from '@horux/shared';
export function useAlertas(filters?: { leida?: boolean; resuelta?: boolean }) {
return useQuery({
queryKey: ['alertas', filters],
queryFn: () => alertasApi.getAlertas(filters),
});
}
export function useAlertasStats() {
return useQuery({
queryKey: ['alertas-stats'],
queryFn: alertasApi.getStats,
});
}
export function useCreateAlerta() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: AlertaCreate) => alertasApi.createAlerta(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alertas'] });
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
},
});
}
export function useUpdateAlerta() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: AlertaUpdate }) => alertasApi.updateAlerta(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alertas'] });
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
},
});
}
export function useDeleteAlerta() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => alertasApi.deleteAlerta(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alertas'] });
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
},
});
}
export function useMarkAllAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: alertasApi.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alertas'] });
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
},
});
}

View File

@@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as calendarioApi from '../api/calendario';
import type { EventoCreate, EventoUpdate } from '@horux/shared';
export function useEventos(año: number, mes?: number) {
return useQuery({
queryKey: ['calendario', año, mes],
queryFn: () => calendarioApi.getEventos(año, mes),
});
}
export function useProximosEventos(dias = 30) {
return useQuery({
queryKey: ['calendario-proximos', dias],
queryFn: () => calendarioApi.getProximos(dias),
});
}
export function useCreateEvento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: EventoCreate) => calendarioApi.createEvento(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendario'] });
},
});
}
export function useUpdateEvento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: EventoUpdate }) => calendarioApi.updateEvento(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendario'] });
},
});
}
export function useDeleteEvento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => calendarioApi.deleteEvento(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendario'] });
},
});
}

View File

@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import * as reportesApi from '../api/reportes';
export function useEstadoResultados(fechaInicio?: string, fechaFin?: string) {
return useQuery({
queryKey: ['estado-resultados', fechaInicio, fechaFin],
queryFn: () => reportesApi.getEstadoResultados(fechaInicio, fechaFin),
});
}
export function useFlujoEfectivo(fechaInicio?: string, fechaFin?: string) {
return useQuery({
queryKey: ['flujo-efectivo', fechaInicio, fechaFin],
queryFn: () => reportesApi.getFlujoEfectivo(fechaInicio, fechaFin),
});
}
export function useComparativo(año?: number) {
return useQuery({
queryKey: ['comparativo', año],
queryFn: () => reportesApi.getComparativo(año),
});
}
export function useConcentradoRfc(tipo: 'cliente' | 'proveedor', fechaInicio?: string, fechaFin?: string) {
return useQuery({
queryKey: ['concentrado-rfc', tipo, fechaInicio, fechaFin],
queryFn: () => reportesApi.getConcentradoRfc(tipo, fechaInicio, fechaFin),
});
}

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as usuariosApi from '../api/usuarios';
import type { UserInvite, UserUpdate } from '@horux/shared';
export function useUsuarios() {
return useQuery({
queryKey: ['usuarios'],
queryFn: usuariosApi.getUsuarios,
});
}
export function useInviteUsuario() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserInvite) => usuariosApi.inviteUsuario(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}
export function useUpdateUsuario() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuario(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}
export function useDeleteUsuario() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => usuariosApi.deleteUsuario(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}