feat: recordatorios periódicos; supervisor invita auxiliares; owner edita usuarios; precios planes y MSI

This commit is contained in:
Horux Dev
2026-06-25 20:56:39 +00:00
parent 0bded50e57
commit 57c4908e68
12 changed files with 770 additions and 102 deletions

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
import { useAuthStore } from '@/stores/auth-store';
import {
@@ -42,6 +42,8 @@ interface RecordatorioForm {
fechaLimite: string;
notas: string;
privado: boolean;
recurrencia: 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
fechaFin: string;
}
const emptyForm: RecordatorioForm = {
@@ -50,6 +52,8 @@ const emptyForm: RecordatorioForm = {
fechaLimite: '',
notas: '',
privado: false,
recurrencia: 'unica',
fechaFin: '',
};
export default function CalendarioPage() {
@@ -100,6 +104,8 @@ export default function CalendarioPage() {
fechaLimite: evento.fechaLimite,
notas: evento.notas || '',
privado: (evento as any).privado ?? false,
recurrencia: (evento.recurrencia as RecordatorioForm['recurrencia']) || 'unica',
fechaFin: '', // La fecha fin no se edita desde el calendario; se mantiene la original
});
setShowForm(true);
};
@@ -113,15 +119,19 @@ export default function CalendarioPage() {
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
});
} else {
await createEvento.mutateAsync({
const payload: any = {
titulo: form.titulo,
descripcion: form.descripcion,
tipo: 'custom',
fechaLimite: form.fechaLimite,
recurrencia: 'unica',
recurrencia: form.recurrencia,
notas: form.notas,
privado: form.privado,
} as any);
};
if (form.recurrencia !== 'unica' && form.fechaFin) {
payload.fechaFin = form.fechaFin;
}
await createEvento.mutateAsync(payload);
}
setShowForm(false);
setForm(emptyForm);
@@ -131,10 +141,15 @@ export default function CalendarioPage() {
}
};
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar este recordatorio?')) return;
const handleDelete = async (evento: EventoFiscal) => {
if (!evento.id) return;
const esPeriodico = evento.recurrencia && evento.recurrencia !== 'unica';
const mensaje = esPeriodico
? 'Esto cancelará todas las ocurrencias futuras de esta serie. ¿Continuar?'
: '¿Eliminar este recordatorio?';
if (!confirm(mensaje)) return;
try {
await deleteEvento.mutateAsync(id);
await deleteEvento.mutateAsync(evento.id);
} catch {
alert('Error al eliminar');
}
@@ -206,6 +221,44 @@ export default function CalendarioPage() {
/>
</div>
</div>
{!editingId && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="recurrencia">Recurrencia</Label>
<Select
value={form.recurrencia}
onValueChange={(v) => setForm({ ...form, recurrencia: v as RecordatorioForm['recurrencia'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unica">Única</SelectItem>
<SelectItem value="mensual">Mensual</SelectItem>
<SelectItem value="bimestral">Bimestral</SelectItem>
<SelectItem value="trimestral">Trimestral</SelectItem>
<SelectItem value="anual">Anual</SelectItem>
</SelectContent>
</Select>
</div>
{form.recurrencia !== 'unica' && (
<div className="space-y-2">
<Label htmlFor="fechaFin">Fecha fin (opcional)</Label>
<Input
id="fechaFin"
type="date"
value={form.fechaFin}
onChange={e => setForm({ ...form, fechaFin: e.target.value })}
/>
</div>
)}
</div>
)}
{editingId && form.recurrencia !== 'unica' && (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Este es un recordatorio periódico. Los cambios se aplicarán a todas las ocurrencias futuras.
</div>
)}
<div className="space-y-2">
<Label htmlFor="descripcion">Descripción (opcional)</Label>
<Input
@@ -417,7 +470,7 @@ export default function CalendarioPage() {
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
onClick={() => evento.id && handleDelete(evento.id)}
onClick={() => handleDelete(evento)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>

View File

@@ -9,7 +9,7 @@ import { addClienteAcceso } from '@/lib/api/contribuyentes';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound, Pencil } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
@@ -79,7 +79,7 @@ export default function UsuariosPage() {
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho
? (currentUser?.role === 'supervisor'
? despachoInviteRoles.filter(r => r.value === 'cliente')
? despachoInviteRoles.filter(r => r.value === 'cliente' || r.value === 'auxiliar')
: despachoInviteRoles)
: legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
@@ -106,6 +106,13 @@ export default function UsuariosPage() {
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
// Edit user modal (owner only)
const [editingUser, setEditingUser] = useState<{ id: string; nombre: string; role: Role; email: string } | null>(null);
const [editForm, setEditForm] = useState<{ nombre: string; role: Role }>({ nombre: '', role: 'auxiliar' });
const [savingUser, setSavingUser] = useState(false);
const isOwner = currentUser?.role === 'owner';
const openEditSupervisor = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
@@ -132,6 +139,24 @@ export default function UsuariosPage() {
}
};
const openEditUser = (usuario: { id: string; nombre: string; role: Role; email: string }) => {
setEditingUser(usuario);
setEditForm({ nombre: usuario.nombre, role: usuario.role });
};
const handleSaveUser = async () => {
if (!editingUser) return;
setSavingUser(true);
try {
await updateUsuario.mutateAsync({ id: editingUser.id, data: editForm });
setEditingUser(null);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al guardar usuario');
} finally {
setSavingUser(false);
}
};
const openEditAccesos = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
@@ -270,7 +295,16 @@ export default function UsuariosPage() {
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
onValueChange={(v) => {
const isAuxiliar = v === 'auxiliar';
const isSupervisor = currentUser?.role === 'supervisor';
setInviteForm({
...inviteForm,
role: v as UserInvite['role'],
supervisorUserId: isAuxiliar && isSupervisor ? currentUser?.id : undefined,
});
if (v !== 'cliente') setSelectedRfcIds([]);
}}
>
<SelectTrigger>
<SelectValue />
@@ -393,6 +427,18 @@ export default function UsuariosPage() {
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
{isOwner && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEditUser(usuario)}
title="Editar nombre y rol"
>
<Pencil className="h-4 w-4 mr-1" /> Editar
</Button>
</div>
)}
{isAdmin && !isCurrentUser && (
<div className="flex gap-1">
{isDespacho && usuario.role === 'cliente' && (
@@ -526,6 +572,67 @@ export default function UsuariosPage() {
</DialogContent>
</Dialog>
)}
{/* Edit User Modal */}
{editingUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar usuario</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-email">Email</Label>
<Input id="edit-email" value={editingUser.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="edit-nombre">Nombre</Label>
<Input
id="edit-nombre"
value={editForm.nombre}
onChange={e => setEditForm({ ...editForm, nombre: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role">Rol</Label>
{editingUser.id === currentUser?.id ? (
<div className="text-sm border rounded-md px-3 py-2 bg-muted text-muted-foreground">
{getRoleInfo(editForm.role, isDespacho).label}
</div>
) : (
<Select
value={editForm.role}
onValueChange={(v) => setEditForm({ ...editForm, role: v as Role })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(isDespacho
? (['owner', 'supervisor', 'auxiliar', 'cliente'] as Role[])
: (['owner', 'cfo', 'contador', 'visor', 'auxiliar'] as Role[])
).map((r) => (
<SelectItem key={r} value={r}>
{getRoleInfo(r, isDespacho).label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{editingUser.id === currentUser?.id && (
<p className="text-xs text-muted-foreground">No puedes cambiar tu propio rol.</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingUser(null)}>Cancelar</Button>
<Button onClick={handleSaveUser} disabled={savingUser}>
{savingUser ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DashboardShell>
);
}