feat: seguimiento auxiliares UI con tabs Asignadas/Sin asignar

- Componente seguimiento-auxiliares.tsx con tabs Asignadas/Sin asignar
- Tabs internos Obligaciones/Tareas en cada vista
- API client y hooks para asignaciones
- Fix: invalidar query sin-asignar al asignar/desasignar
This commit is contained in:
Horux Dev
2026-05-23 23:40:39 +00:00
parent f43cb165c6
commit e8b0733304
4 changed files with 546 additions and 33 deletions

View File

@@ -5,12 +5,13 @@ import {
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsList, TabsTrigger, TabsContent,
cn,
} from '@horux/shared-ui';
import { useQueryClient } from '@tanstack/react-query';
import {
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
Users, Building2, FolderPlus, UserCog,
Users, Building2, FolderPlus, UserCog, ClipboardList,
} from 'lucide-react';
import {
useCarteras, useCreateCartera, useDeleteCartera,
@@ -25,14 +26,16 @@ import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import type { Cartera } from '@/lib/api/carteras';
import SeguimientoAuxiliares from './seguimiento-auxiliares';
/* ------------------------------------------------------------------ */
/* SubcarteraCard */
/* ------------------------------------------------------------------ */
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
function SubcarteraCard({ sub, usuarios, contribuyentes, parentEntidadIds, onDelete }: {
sub: Cartera;
usuarios: any[];
contribuyentes: any[];
parentEntidadIds: string[];
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
@@ -47,7 +50,7 @@ function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
);
const available = (contribuyentes ?? []).filter(
(c: any) => !(entidadIds ?? []).includes(c.id)
(c: any) => (parentEntidadIds ?? []).includes(c.id) && !(entidadIds ?? []).includes(c.id)
);
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
@@ -319,6 +322,7 @@ function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }:
sub={sub}
usuarios={usuarios ?? []}
contribuyentes={contribuyentes ?? []}
parentEntidadIds={entidadIds ?? []}
onDelete={() => handleDeleteSubcartera(sub.id)}
/>
))}
@@ -396,6 +400,10 @@ export default function CarterasPage() {
const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs
const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
const isAuxiliar = userRole === 'auxiliar';
const isSupervisor = userRole === 'supervisor';
const isOwner = userRole === 'owner';
const puedeVerSeguimiento = isOwner || isSupervisor;
const [activeTab, setActiveTab] = useState('carteras');
const { data: carteras, isLoading } = useCarteras();
const { data: supervisores } = useSupervisores();
const { data: usuarios } = useUsuarios();
@@ -440,24 +448,8 @@ export default function CarterasPage() {
}
};
return (
<DashboardShell title="Carteras">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
</p>
</div>
{canCreate && (
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nueva cartera
</Button>
)}
</div>
{/* List */}
const CarterasList = () => (
<>
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
@@ -487,6 +479,48 @@ export default function CarterasPage() {
))}
</div>
)}
</>
);
return (
<DashboardShell title="Carteras">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
</p>
</div>
{canCreate && activeTab === 'carteras' && (
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nueva cartera
</Button>
)}
</div>
{puedeVerSeguimiento ? (
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="carteras" className="space-y-4">
<TabsList>
<TabsTrigger value="carteras">
<FolderOpen className="h-4 w-4 mr-1.5" /> Carteras
</TabsTrigger>
<TabsTrigger value="seguimiento">
<ClipboardList className="h-4 w-4 mr-1.5" /> Seguimiento de Auxiliares
</TabsTrigger>
</TabsList>
<TabsContent value="carteras">
<CarterasList />
</TabsContent>
<TabsContent value="seguimiento">
<SeguimientoAuxiliares />
</TabsContent>
</Tabs>
) : (
<CarterasList />
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>

View File

@@ -0,0 +1,332 @@
'use client';
import { useState } from 'react';
import {
Button, Card, CardContent, CardHeader, CardTitle,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsList, TabsTrigger, TabsContent,
} from '@horux/shared-ui';
import {
useAsignacionesSupervisor,
useSinAsignar,
useAsignarObligacion,
useDesasignarObligacion,
useAsignarTarea,
useDesasignarTarea,
} from '@/lib/hooks/use-asignaciones';
import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { UserCheck, UserX, UserCog, Plus } from 'lucide-react';
export default function SeguimientoAuxiliares() {
const { user } = useAuthStore();
const { data: asignaciones, isLoading: loadingAsignadas } = useAsignacionesSupervisor();
const { data: sinAsignar, isLoading: loadingSinAsignar } = useSinAsignar();
const { data: usuarios } = useUsuarios();
const asignarObligacionMut = useAsignarObligacion();
const desasignarObligacionMut = useDesasignarObligacion();
const asignarTareaMut = useAsignarTarea();
const desasignarTareaMut = useDesasignarTarea();
const [modalOpen, setModalOpen] = useState(false);
const [modalType, setModalType] = useState<'obligacion' | 'tarea'>('obligacion');
const [modalItem, setModalItem] = useState<any>(null);
const [selectedAuxiliar, setSelectedAuxiliar] = useState('');
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => {
setModalType(type);
setModalItem(item);
setSelectedAuxiliar(item.auxiliarUserId || '');
setModalOpen(true);
};
const handleAssign = async () => {
if (!selectedAuxiliar || !modalItem) return;
try {
if (modalType === 'obligacion') {
await asignarObligacionMut.mutateAsync({
contribuyenteId: modalItem.contribuyenteId,
obligacionId: modalItem.obligacionId,
auxiliarUserId: selectedAuxiliar,
});
} else {
await asignarTareaMut.mutateAsync({
tareaId: modalItem.tareaId,
auxiliarUserId: selectedAuxiliar,
});
}
setModalOpen(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al asignar');
}
};
const handleUnassign = async (type: 'obligacion' | 'tarea', item: any) => {
if (!confirm('¿Eliminar la asignación?')) return;
try {
if (type === 'obligacion') {
await desasignarObligacionMut.mutateAsync({
contribuyenteId: item.contribuyenteId,
obligacionId: item.obligacionId,
});
} else {
await desasignarTareaMut.mutateAsync({ tareaId: item.tareaId });
}
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desasignar');
}
};
if (loadingAsignadas || loadingSinAsignar) {
return <p className="text-muted-foreground">Cargando asignaciones...</p>;
}
const obligacionesAsignadas = asignaciones?.obligaciones ?? [];
const tareasAsignadas = asignaciones?.tareas ?? [];
const obligacionesSinAsignar = sinAsignar?.obligaciones ?? [];
const tareasSinAsignar = sinAsignar?.tareas ?? [];
return (
<div className="space-y-4">
<Tabs defaultValue="asignadas" className="space-y-4">
<TabsList>
<TabsTrigger value="asignadas">Asignadas</TabsTrigger>
<TabsTrigger value="sin-asignar">Sin asignar</TabsTrigger>
</TabsList>
<TabsContent value="asignadas">
<Tabs defaultValue="obligaciones" className="space-y-4">
<TabsList>
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesAsignadas.length})</TabsTrigger>
<TabsTrigger value="tareas">Tareas ({tareasAsignadas.length})</TabsTrigger>
</TabsList>
<TabsContent value="obligaciones">
<AsignacionesTable
items={obligacionesAsignadas}
tipo="obligacion"
modo="asignadas"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('obligacion', item)}
onUnassign={(item) => handleUnassign('obligacion', item)}
/>
</TabsContent>
<TabsContent value="tareas">
<AsignacionesTable
items={tareasAsignadas}
tipo="tarea"
modo="asignadas"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('tarea', item)}
onUnassign={(item) => handleUnassign('tarea', item)}
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="sin-asignar">
<Tabs defaultValue="obligaciones" className="space-y-4">
<TabsList>
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesSinAsignar.length})</TabsTrigger>
<TabsTrigger value="tareas">Tareas ({tareasSinAsignar.length})</TabsTrigger>
</TabsList>
<TabsContent value="obligaciones">
<SinAsignarTable
items={obligacionesSinAsignar}
tipo="obligacion"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('obligacion', item)}
/>
</TabsContent>
<TabsContent value="tareas">
<SinAsignarTable
items={tareasSinAsignar}
tipo="tarea"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('tarea', item)}
/>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
{/* Modal de asignación */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'} {modalType === 'obligacion' ? 'obligación' : 'tarea'}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground mb-2">
{modalType === 'obligacion' ? modalItem?.obligacionNombre : modalItem?.tareaNombre}
</p>
<p className="text-xs text-muted-foreground mb-4">
Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})
</p>
<Select value={selectedAuxiliar} onValueChange={setSelectedAuxiliar}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un auxiliar" />
</SelectTrigger>
<SelectContent>
{auxiliares.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={handleAssign} disabled={!selectedAuxiliar}>
{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function AsignacionesTable({
items,
tipo,
modo,
auxiliares,
onAssign,
onUnassign,
}: {
items: any[];
tipo: 'obligacion' | 'tarea';
modo: 'asignadas';
auxiliares: any[];
onAssign: (item: any) => void;
onUnassign: (item: any) => void;
}) {
if (items.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} asignadas.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Auxiliar</th>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
<th className="text-left px-4 py-3 font-medium">Asignado</th>
<th className="text-right px-4 py-3 font-medium">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
{item.auxiliarNombre ? (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
<UserCheck className="h-3 w-3" /> {item.auxiliarNombre}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-border text-muted-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
<UserX className="h-3 w-3" /> Sin asignar
</span>
)}
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
</td>
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
<td className="px-4 py-3 text-muted-foreground">
{item.asignadoAt ? new Date(item.asignadoAt).toLocaleDateString('es-MX') : '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
<UserCog className="h-4 w-4" />
</Button>
{item.auxiliarUserId && (
<Button variant="ghost" size="sm" className="text-red-600" onClick={() => onUnassign(item)}>
<UserX className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function SinAsignarTable({
items,
tipo,
auxiliares,
onAssign,
}: {
items: any[];
tipo: 'obligacion' | 'tarea';
auxiliares: any[];
onAssign: (item: any) => void;
}) {
if (items.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} sin asignar.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
<th className="text-right px-4 py-3 font-medium">Acción</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, idx) => (
<tr key={`${item.obligacionId || item.tareaId}-${idx}`} className="hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
</td>
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
<Plus className="h-4 w-4 mr-1" /> Asignar
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
import { apiClient } from './client';
export interface AsignacionObligacion {
id: string;
obligacionId: string;
obligacionNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionTarea {
id: string;
tareaId: string;
tareaNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionesResponse {
obligaciones: AsignacionObligacion[];
tareas: AsignacionTarea[];
}
export const getAsignacionesPorSupervisor = () =>
apiClient.get<AsignacionesResponse>('/carteras/asignaciones').then(r => r.data);
export const getAsignacionesPorAuxiliar = () =>
apiClient.get<AsignacionesResponse>('/carteras/asignaciones/mias').then(r => r.data);
export interface SinAsignarResponse {
obligaciones: Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[];
tareas: Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[];
}
export const getSinAsignar = () =>
apiClient.get<SinAsignarResponse>('/carteras/asignaciones/sin-asignar').then(r => r.data);
export const asignarObligacion = (contribuyenteId: string, obligacionId: string, auxiliarUserId: string) =>
apiClient.post(`/contribuyentes/${contribuyenteId}/obligaciones/${obligacionId}/asignar`, { auxiliarUserId }).then(r => r.data);
export const desasignarObligacion = (contribuyenteId: string, obligacionId: string) =>
apiClient.delete(`/contribuyentes/${contribuyenteId}/obligaciones/${obligacionId}/asignar`).then(r => r.data);
export const asignarTarea = (tareaId: string, auxiliarUserId: string) =>
apiClient.post(`/tareas/${tareaId}/asignar`, { auxiliarUserId }).then(r => r.data);
export const desasignarTarea = (tareaId: string) =>
apiClient.delete(`/tareas/${tareaId}/asignar`).then(r => r.data);

View File

@@ -0,0 +1,89 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getAsignacionesPorSupervisor,
getAsignacionesPorAuxiliar,
getSinAsignar,
asignarObligacion,
desasignarObligacion,
asignarTarea,
desasignarTarea,
} from '../api/asignaciones';
export function useAsignacionesSupervisor() {
return useQuery({
queryKey: ['asignaciones-supervisor'],
queryFn: getAsignacionesPorSupervisor,
});
}
export function useAsignacionesAuxiliar() {
return useQuery({
queryKey: ['asignaciones-auxiliar'],
queryFn: getAsignacionesPorAuxiliar,
});
}
export function useSinAsignar() {
return useQuery({
queryKey: ['asignaciones-sin-asignar'],
queryFn: getSinAsignar,
});
}
export function useAsignarObligacion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contribuyenteId, obligacionId, auxiliarUserId }: {
contribuyenteId: string;
obligacionId: string;
auxiliarUserId: string;
}) => asignarObligacion(contribuyenteId, obligacionId, auxiliarUserId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['obligaciones'] });
},
});
}
export function useDesasignarObligacion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contribuyenteId, obligacionId }: {
contribuyenteId: string;
obligacionId: string;
}) => desasignarObligacion(contribuyenteId, obligacionId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['obligaciones'] });
},
});
}
export function useAsignarTarea() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tareaId, auxiliarUserId }: {
tareaId: string;
auxiliarUserId: string;
}) => asignarTarea(tareaId, auxiliarUserId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}
export function useDesasignarTarea() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tareaId }: { tareaId: string }) => desasignarTarea(tareaId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}