Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/calendario/page.tsx
Horux Dev 66d68c652c Revert "feat(ui): make dashboard responsive for iPhone and mobile devices"
This reverts commit d3b326e.

The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
2026-06-13 20:16:04 +00:00

438 lines
18 KiB
TypeScript

'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
import { useAuthStore } from '@/stores/auth-store';
import {
Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText,
CreditCard, Plus, X, Pencil, Trash2, Lock, Globe, AlertTriangle,
} from 'lucide-react';
import { cn } from '@horux/shared-ui';
import type { EventoFiscal } from '@horux/shared';
const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const tipoIcons: Record<string, any> = {
declaracion: FileText,
pago: CreditCard,
obligacion: Clock,
informativa: FileText,
custom: Calendar,
'obligacion-pendiente': Clock,
'obligacion-completada': Check,
'obligacion-atrasada': AlertTriangle,
};
const tipoColors: Record<string, string> = {
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',
informativa: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
custom: 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200',
'obligacion-pendiente': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
'obligacion-completada': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'obligacion-atrasada': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
interface RecordatorioForm {
titulo: string;
descripcion: string;
fechaLimite: string;
notas: string;
privado: boolean;
}
const emptyForm: RecordatorioForm = {
titulo: '',
descripcion: '',
fechaLimite: '',
notas: '',
privado: false,
};
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);
const createEvento = useCreateEvento();
const updateEvento = useUpdateEvento();
const deleteEvento = useDeleteEvento();
const { user } = useAuthStore();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<RecordatorioForm>(emptyForm);
const canEdit = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(user?.role || '');
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 = (evento: EventoFiscal) => {
if (!evento.id) return;
if (evento.tipo === 'custom') {
updateEvento.mutate({ id: evento.id, data: { completado: !evento.completado } });
}
};
const handleOpenCreate = () => {
setEditingId(null);
const defaultDate = `${año}-${String(mes).padStart(2, '0')}-15`;
setForm({ ...emptyForm, fechaLimite: defaultDate });
setShowForm(true);
};
const handleOpenEdit = (evento: EventoFiscal) => {
if (!evento.id || evento.tipo !== 'custom') return;
setEditingId(evento.id);
setForm({
titulo: evento.titulo,
descripcion: evento.descripcion || '',
fechaLimite: evento.fechaLimite,
notas: evento.notas || '',
privado: (evento as any).privado ?? false,
});
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingId) {
await updateEvento.mutateAsync({
id: editingId,
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
});
} else {
await createEvento.mutateAsync({
titulo: form.titulo,
descripcion: form.descripcion,
tipo: 'custom',
fechaLimite: form.fechaLimite,
recurrencia: 'unica',
notas: form.notas,
privado: form.privado,
} as any);
}
setShowForm(false);
setForm(emptyForm);
setEditingId(null);
} catch {
alert('Error al guardar recordatorio');
}
};
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar este recordatorio?')) return;
try {
await deleteEvento.mutateAsync(id);
} catch {
alert('Error al eliminar');
}
};
const handleCancelForm = () => {
setShowForm(false);
setForm(emptyForm);
setEditingId(null);
};
// 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 + 'T00:00:00');
return fecha.getFullYear() === año && fecha.getMonth() + 1 === mes && fecha.getDate() === day;
}) || [];
};
const eventosDelMes = eventos?.filter(e => {
const f = new Date(e.fechaLimite + 'T00:00:00');
return f.getFullYear() === año && f.getMonth() + 1 === mes;
}) || [];
return (
<DashboardShell title="Calendario Fiscal">
{/* Modal de crear/editar */}
{showForm && (
<Card className="mb-4">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{editingId ? 'Editar Recordatorio' : 'Nuevo Recordatorio'}
</CardTitle>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="titulo">Título</Label>
<Input
id="titulo"
value={form.titulo}
onChange={e => setForm({ ...form, titulo: e.target.value })}
placeholder="Reunión con contador"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="fechaLimite">Fecha</Label>
<Input
id="fechaLimite"
type="date"
value={form.fechaLimite}
onChange={e => setForm({ ...form, fechaLimite: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">Descripción (opcional)</Label>
<Input
id="descripcion"
value={form.descripcion}
onChange={e => setForm({ ...form, descripcion: e.target.value })}
placeholder="Revisión de declaración mensual"
/>
</div>
<div className="space-y-2">
<Label htmlFor="notas">Notas (opcional)</Label>
<Input
id="notas"
value={form.notas}
onChange={e => setForm({ ...form, notas: e.target.value })}
placeholder="Llevar estados de cuenta"
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setForm({ ...form, privado: !form.privado })}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors',
form.privado
? 'border-orange-300 bg-orange-50 text-orange-700 dark:border-orange-700 dark:bg-orange-950 dark:text-orange-300'
: 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-300'
)}
>
{form.privado ? <Lock className="h-4 w-4" /> : <Globe className="h-4 w-4" />}
{form.privado ? 'Privado' : 'Público'}
</button>
<span className="text-xs text-muted-foreground">
{form.privado ? 'Solo tú puedes verlo' : 'Visible para todo el equipo'}
</span>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancelar
</Button>
<Button type="submit" disabled={createEvento.isPending || updateEvento.isPending}>
{editingId
? (updateEvento.isPending ? 'Guardando...' : 'Guardar')
: (createEvento.isPending ? 'Creando...' : 'Crear Recordatorio')}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<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">
{canEdit && !showForm && (
<Button variant="outline" size="sm" onClick={handleOpenCreate} className="mr-2">
<Plus className="h-4 w-4 mr-1" />
Recordatorio
</Button>
)}
<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>
{/* Leyenda de colores por estado de obligación */}
<div className="flex items-center gap-3 flex-wrap text-xs text-muted-foreground mb-3 pb-2 border-b">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-amber-400" />
Pendiente
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-green-500" />
Completada
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-red-500" />
Atrasada
</span>
<span className="inline-flex items-center gap-1.5 ml-2">
<span className="h-2.5 w-2.5 rounded bg-violet-400" />
Recordatorio custom
</span>
</div>
<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, idx) => {
const Icon = tipoIcons[e.tipo] || Calendar;
return (
<div
key={`${e.id}-${idx}`}
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',
e.tipo === 'custom' && canEdit && 'cursor-pointer'
)}
title={e.titulo}
onClick={() => e.tipo === 'custom' && canEdit && handleOpenEdit(e)}
>
<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>
) : eventosDelMes.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">No hay eventos este mes</div>
) : (
<div className="space-y-3">
{eventosDelMes.map((evento, idx) => {
const Icon = tipoIcons[evento.tipo] || FileText;
const isCustom = evento.tipo === 'custom';
return (
<div
key={`${evento.fechaLimite}-${evento.titulo}-${idx}`}
className={cn(
'p-3 rounded-lg border',
evento.completado && 'opacity-50'
)}
>
<div className="flex items-start gap-2">
<div className={cn('p-1.5 rounded', tipoColors[evento.tipo] || 'bg-muted')}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<h4 className={cn('font-medium text-sm truncate', evento.completado && 'line-through')}>
{evento.titulo}
</h4>
{isCustom && (evento as any).privado && (
<Lock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
{evento.descripcion && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{evento.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
})}
</p>
</div>
{isCustom && canEdit && (
<div className="flex gap-1 flex-shrink-0">
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleComplete(evento)}
title={evento.completado ? 'Marcar pendiente' : 'Marcar completado'}
>
<Check className={cn('h-3.5 w-3.5', evento.completado && 'text-green-600')} />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleOpenEdit(evento)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
onClick={() => evento.id && handleDelete(evento.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}