feat: recordatorios periódicos; supervisor invita auxiliares; owner edita usuarios; precios planes y MSI
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user