- Add Sheet primitive component for mobile drawers - Add MobileNav with hamburger menu for dashboard layout - Hide desktop sidebars on mobile; show mobile header - Make dashboard header responsive with stacked layout on small screens - Hide selector text on mobile, show icons only - Convert fixed-width filters to responsive widths (CFDI, Clientes, Admin, Documentos, Alertas) - Cap dialog widths to 95vw on mobile (CFDI viewer, Documentos, Reportes, Contribuyentes, Facturación) - Make calendar grid smaller and use single-letter weekdays on mobile - Update viewport to include viewport-fit=cover for Samsung safe areas
439 lines
18 KiB
TypeScript
439 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-xs md:text-sm font-medium text-muted-foreground py-1 md:py-2">
|
|
<span className="md:hidden">{d.charAt(0)}</span>
|
|
<span className="hidden md:inline">{d}</span>
|
|
</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-[60px] md:min-h-[80px] p-0.5 md:p-1 border rounded-md',
|
|
day ? 'bg-background' : 'bg-muted/30',
|
|
isToday && 'ring-2 ring-primary'
|
|
)}
|
|
>
|
|
{day && (
|
|
<>
|
|
<div className={cn('text-xs md: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>
|
|
);
|
|
}
|