- Add bulk XML CFDI upload support (up to 300MB) - Add period selector component for month/year navigation - Fix session persistence on page refresh (Zustand hydration) - Fix income/expense classification based on tenant RFC - Fix IVA calculation from XML (correct Impuestos element) - Add error handling to reportes page - Support multiple CORS origins - Update reportes service with proper Decimal/BigInt handling - Add RFC to tenant view store for proper CFDI classification - Update README with changelog and new features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
203 lines
7.8 KiB
TypeScript
203 lines
7.8 KiB
TypeScript
'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">
|
|
<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>
|
|
);
|
|
}
|