Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/carteras/page.tsx

540 lines
23 KiB
TypeScript

'use client';
import { useState } from 'react';
import {
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
cn,
} from '@horux/shared-ui';
import { useQueryClient } from '@tanstack/react-query';
import {
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
Users, Building2, FolderPlus, UserCog,
} from 'lucide-react';
import {
useCarteras, useCreateCartera, useDeleteCartera,
useCarteraEntidades, useSubcarteras, useCreateSubcartera,
useSupervisores,
} from '@/lib/hooks/use-carteras';
import {
addEntidadToCartera, removeEntidadFromCartera,
} from '@/lib/api/carteras';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
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';
/* ------------------------------------------------------------------ */
/* SubcarteraCard */
/* ------------------------------------------------------------------ */
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
sub: Cartera;
usuarios: any[];
contribuyentes: any[];
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const qc = useQueryClient();
const { data: entidadIds, isLoading } = useCarteraEntidades(expanded ? sub.id : null);
const [addingEntidad, setAddingEntidad] = useState(false);
const [selectedEntidadId, setSelectedEntidadId] = useState('');
const [busy, setBusy] = useState(false);
const entidadMap = Object.fromEntries(
(contribuyentes ?? []).map((c: any) => [c.id, { rfc: c.rfc, nombre: c.nombre }])
);
const available = (contribuyentes ?? []).filter(
(c: any) => !(entidadIds ?? []).includes(c.id)
);
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['cartera-entidades', sub.id] });
qc.invalidateQueries({ queryKey: ['subcarteras'] });
qc.invalidateQueries({ queryKey: ['carteras'] });
};
const handleAddEntidad = async () => {
if (!selectedEntidadId) return;
setBusy(true);
try {
await addEntidadToCartera(sub.id, selectedEntidadId);
setSelectedEntidadId('');
setAddingEntidad(false);
invalidate();
} finally { setBusy(false); }
};
const handleRemoveEntidad = async (entidadId: string) => {
setBusy(true);
try {
await removeEntidadFromCartera(sub.id, entidadId);
invalidate();
} finally { setBusy(false); }
};
return (
<div className="border rounded-lg p-3 bg-muted/20">
<div className="flex items-center justify-between">
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
<UserCog className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium text-sm">{sub.nombre}</span>
{auxiliarUser && (
<span className="text-xs text-muted-foreground ml-2">({auxiliarUser.nombre})</span>
)}
</div>
<span className="text-xs text-muted-foreground ml-auto mr-2">{sub.entidadesCount} RFCs</span>
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
<button onClick={onDelete} className="text-muted-foreground hover:text-destructive p-1">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{expanded && (
<div className="mt-3 space-y-2">
{!addingEntidad && (
<Button variant="ghost" size="sm" onClick={() => setAddingEntidad(true)} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" /> Asignar RFC
</Button>
)}
{addingEntidad && (
<div className="flex items-center gap-2">
<select className="flex-1 rounded-md border bg-background px-2 py-1 text-sm" value={selectedEntidadId} onChange={e => setSelectedEntidadId(e.target.value)}>
<option value="">-- Seleccionar RFC --</option>
{available.map((c: any) => <option key={c.id} value={c.id}>{c.rfc} {c.nombre}</option>)}
</select>
<Button size="sm" onClick={handleAddEntidad} disabled={!selectedEntidadId || busy}>Agregar</Button>
<Button size="sm" variant="ghost" onClick={() => { setAddingEntidad(false); setSelectedEntidadId(''); }}>Cancelar</Button>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !entidadIds || entidadIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin RFCs asignados a esta subcartera.</p>
) : (
<ul className="space-y-1">
{entidadIds.map(id => {
const info = entidadMap[id];
return (
<li key={id} className="flex items-center justify-between bg-background rounded px-2 py-1 text-sm">
<span>{info ? <><span className="font-mono text-xs">{info.rfc}</span> <span className="text-muted-foreground">{info.nombre}</span></> : id}</span>
<button onClick={() => handleRemoveEntidad(id)} disabled={busy} className="text-muted-foreground hover:text-destructive"><X className="h-3 w-3" /></button>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* CarteraDetail */
/* ------------------------------------------------------------------ */
function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }: { cartera: Cartera; canEdit?: boolean; canManageSubcarteras?: boolean }) {
const qc = useQueryClient();
const { data: contribuyentes } = useContribuyentes();
const { data: usuarios } = useUsuarios();
const { data: entidadIds, isLoading: loadingEntidades } = useCarteraEntidades(cartera.id);
const { data: subcarteras, isLoading: loadingSubs } = useSubcarteras(cartera.id);
const createSub = useCreateSubcartera();
const [addingEntidad, setAddingEntidad] = useState(false);
const [selectedEntidadId, setSelectedEntidadId] = useState('');
const [showCreateSub, setShowCreateSub] = useState(false);
const [subForm, setSubForm] = useState({ nombre: '', auxiliarUserId: '' });
const [busy, setBusy] = useState(false);
const entidadMap = Object.fromEntries(
(contribuyentes ?? []).map((c) => [c.id, { rfc: c.rfc, nombre: c.nombre }])
);
const available = (contribuyentes ?? []).filter(
(c) => !(entidadIds ?? []).includes(c.id)
);
// Auxiliares available for subcarteras (those assigned to this supervisor)
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['cartera-entidades', cartera.id] });
qc.invalidateQueries({ queryKey: ['subcarteras', cartera.id] });
qc.invalidateQueries({ queryKey: ['carteras'] });
};
const handleAddEntidad = async () => {
if (!selectedEntidadId) return;
setBusy(true);
try {
await addEntidadToCartera(cartera.id, selectedEntidadId);
setSelectedEntidadId('');
setAddingEntidad(false);
invalidate();
} finally { setBusy(false); }
};
const handleRemoveEntidad = async (entidadId: string) => {
setBusy(true);
try {
await removeEntidadFromCartera(cartera.id, entidadId);
invalidate();
} finally { setBusy(false); }
};
const handleCreateSubcartera = async () => {
if (!subForm.nombre.trim() || !subForm.auxiliarUserId) return;
try {
await createSub.mutateAsync({
carteraId: cartera.id,
nombre: subForm.nombre.trim(),
auxiliarUserId: subForm.auxiliarUserId,
});
setSubForm({ nombre: '', auxiliarUserId: '' });
setShowCreateSub(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear subcartera');
}
};
const handleDeleteSubcartera = async (subId: string) => {
if (!confirm('¿Eliminar esta subcartera?')) return;
try {
const { deleteCartera } = await import('@/lib/api/carteras');
await deleteCartera(subId);
invalidate();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<div className="border-t mt-4 pt-4 space-y-6">
{/* Supervisor info */}
{supervisorUser && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<UserCog className="h-3.5 w-3.5" />
Supervisor: <span className="font-medium text-foreground">{supervisorUser.nombre}</span> ({supervisorUser.email})
</div>
)}
{/* ---- Contribuyentes ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-muted-foreground" />
Contribuyentes ({entidadIds?.length || 0})
</h3>
{canEdit && !addingEntidad && (
<Button variant="ghost" size="sm" onClick={() => setAddingEntidad(true)} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" /> Agregar
</Button>
)}
</div>
{canEdit && addingEntidad && (
<div className="flex items-center gap-2 mb-3">
<select className="flex-1 rounded-md border bg-background px-3 py-1.5 text-sm" value={selectedEntidadId} onChange={e => setSelectedEntidadId(e.target.value)}>
<option value="">-- Seleccionar RFC --</option>
{available.map(c => <option key={c.id} value={c.id}>{c.rfc} {c.nombre}</option>)}
</select>
<Button size="sm" onClick={handleAddEntidad} disabled={!selectedEntidadId || busy}>Agregar</Button>
<Button size="sm" variant="ghost" onClick={() => { setAddingEntidad(false); setSelectedEntidadId(''); }}>Cancelar</Button>
</div>
)}
{loadingEntidades ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !entidadIds || entidadIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin contribuyentes asignados.</p>
) : (
<ul className="space-y-1">
{entidadIds.map(id => {
const info = entidadMap[id];
return (
<li key={id} className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-1.5 text-sm">
<span>{info ? <><span className="font-mono text-xs">{info.rfc}</span> <span className="text-muted-foreground ml-2">{info.nombre}</span></> : <span className="font-mono text-xs">{id}</span>}</span>
{canEdit && <button onClick={() => handleRemoveEntidad(id)} disabled={busy} className="text-muted-foreground hover:text-destructive ml-2"><X className="h-3.5 w-3.5" /></button>}
</li>
);
})}
</ul>
)}
</div>
{/* ---- Subcarteras ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
Subcarteras ({subcarteras?.length || 0})
</h3>
{canManageSubcarteras && !showCreateSub && (
<Button variant="ghost" size="sm" onClick={() => setShowCreateSub(true)} className="h-7 gap-1 text-xs">
<FolderPlus className="h-3 w-3" /> Nueva subcartera
</Button>
)}
</div>
{canManageSubcarteras && showCreateSub && (
<div className="border rounded-lg p-3 mb-3 space-y-3 bg-muted/20">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Nombre</Label>
<Input value={subForm.nombre} onChange={e => setSubForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Cartera de María" className="h-8 text-sm mt-1" />
</div>
<div>
<Label className="text-xs">Auxiliar</Label>
<select className="w-full h-8 rounded-md border bg-background px-2 text-sm mt-1" value={subForm.auxiliarUserId} onChange={e => setSubForm(p => ({ ...p, auxiliarUserId: e.target.value }))}>
<option value="">-- Seleccionar auxiliar --</option>
{auxiliares.map((u: any) => <option key={u.id} value={u.id}>{u.nombre} ({u.email})</option>)}
</select>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleCreateSubcartera} disabled={!subForm.nombre.trim() || !subForm.auxiliarUserId || createSub.isPending}>Crear</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowCreateSub(false); setSubForm({ nombre: '', auxiliarUserId: '' }); }}>Cancelar</Button>
</div>
</div>
)}
{loadingSubs ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !subcarteras || subcarteras.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin subcarteras. Crea una para asignar RFCs a un auxiliar.</p>
) : (
<div className="space-y-2">
{subcarteras.map(sub => (
<SubcarteraCard
key={sub.id}
sub={sub}
usuarios={usuarios ?? []}
contribuyentes={contribuyentes ?? []}
onDelete={() => handleDeleteSubcartera(sub.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* CarteraCard */
/* ------------------------------------------------------------------ */
function CarteraCard({ cartera, expanded, onToggle, onDelete, usuarios, canEdit, canManageSubcarteras }: {
cartera: Cartera;
expanded: boolean;
onToggle: () => void;
onDelete: () => void;
usuarios: any[];
canEdit: boolean;
canManageSubcarteras: boolean;
}) {
const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId);
return (
<Card className={cn('transition-shadow', expanded && 'ring-1 ring-primary/30 shadow-md')}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<button onClick={onToggle} className="flex-1 text-left flex items-center gap-2 group">
<FolderOpen className={cn('h-5 w-5 flex-shrink-0 transition-colors', expanded ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground')} />
<div>
<CardTitle className="text-base">{cartera.nombre}</CardTitle>
{cartera.descripcion && <p className="text-xs text-muted-foreground mt-0.5">{cartera.descripcion}</p>}
</div>
{expanded ? <ChevronUp className="h-4 w-4 text-muted-foreground ml-auto" /> : <ChevronDown className="h-4 w-4 text-muted-foreground ml-auto" />}
</button>
{canEdit && (
<Button variant="ghost" size="sm" onClick={onDelete} className="text-destructive hover:text-destructive flex-shrink-0 h-8 w-8 p-0">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-4 mt-1 pl-7">
{supervisorUser && (
<span className="text-xs text-muted-foreground">
<UserCog className="inline h-3 w-3 mr-1" />
{supervisorUser.nombre}
</span>
)}
<span className="text-xs text-muted-foreground">
<Building2 className="inline h-3 w-3 mr-1" />
{cartera.entidadesCount} RFCs
</span>
<span className="text-xs text-muted-foreground">
<Users className="inline h-3 w-3 mr-1" />
{cartera.subcarterasCount} subcarteras
</span>
</div>
</CardHeader>
{expanded && (
<CardContent className="pt-0">
<CarteraDetail cartera={cartera} canEdit={canEdit} canManageSubcarteras={canManageSubcarteras} />
</CardContent>
)}
</Card>
);
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function CarterasPage() {
const { user } = useAuthStore();
const userRole = user?.role || 'visor';
const canCreate = userRole === 'owner'; // Create top-level carteras
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 { data: carteras, isLoading } = useCarteras();
const { data: supervisores } = useSupervisores();
const { data: usuarios } = useUsuarios();
const createMut = useCreateCartera();
const deleteMut = useDeleteCartera();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ nombre: '', descripcion: '', supervisorUserId: '' });
const hasSupervisores = supervisores && supervisores.length > 0;
const resetForm = () => {
setForm({ nombre: '', descripcion: '', supervisorUserId: '' });
setShowCreate(false);
};
const handleCreate = async () => {
if (!form.nombre.trim()) return;
try {
const supervisorUserId = form.supervisorUserId && form.supervisorUserId !== '__self__'
? form.supervisorUserId : undefined;
const cartera = await createMut.mutateAsync({
nombre: form.nombre.trim(),
descripcion: form.descripcion.trim() || undefined,
supervisorUserId,
});
resetForm();
setExpandedId(cartera.id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear cartera');
}
};
const handleDelete = async (cartera: Cartera) => {
if (!confirm(`¿Eliminar la cartera "${cartera.nombre}"? Se eliminarán también sus subcarteras.`)) return;
try {
await deleteMut.mutateAsync(cartera.id);
if (expandedId === cartera.id) setExpandedId(null);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar cartera');
}
};
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 */}
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin carteras</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Crea la primera cartera para organizar tus contribuyentes.
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nueva cartera</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Nombre *</Label>
<Input value={form.nombre} onChange={e => setForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Clientes CDMX" autoFocus />
</div>
<div>
<Label>Descripcion (opcional)</Label>
<Input value={form.descripcion} onChange={e => setForm(p => ({ ...p, descripcion: e.target.value }))} placeholder="Descripcion breve" />
</div>
{hasSupervisores ? (
<div>
<Label>Asignar a supervisor</Label>
<Select value={form.supervisorUserId} onValueChange={v => setForm(p => ({ ...p, supervisorUserId: v }))}>
<SelectTrigger>
<SelectValue placeholder="Yo mismo (Owner)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__self__">Yo mismo (Owner)</SelectItem>
{supervisores!.map(s => (
<SelectItem key={s.userId} value={s.userId}>{s.nombre} ({s.email})</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">Si no seleccionas, la cartera se asigna a ti.</p>
</div>
) : (
<p className="text-xs text-muted-foreground border rounded-md p-3 bg-muted/30">
No hay supervisores registrados. La cartera se asignará a ti como owner.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button onClick={handleCreate} disabled={!form.nombre.trim() || createMut.isPending}>
{createMut.isPending ? 'Creando...' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardShell>
);
}