feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos
Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages
This commit is contained in:
169
apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx
Normal file
169
apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import { usePagosSinFactura, useEmitirFacturaPago } from '@/lib/hooks/use-pagos-sin-factura';
|
||||
import { ShieldAlert, FileText, RefreshCw, CheckCircle, AlertCircle, Receipt } from 'lucide-react';
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
trial: 'Trial',
|
||||
custom: 'Custom',
|
||||
mi_empresa: 'Mi Empresa',
|
||||
mi_empresa_plus: 'Mi Empresa Plus',
|
||||
business_control: 'Business Control',
|
||||
business_cloud: 'Enterprise',
|
||||
};
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
master: 'Mastercard',
|
||||
visa: 'Visa',
|
||||
amex: 'Amex',
|
||||
debmaster: 'Débito Mastercard',
|
||||
debvisa: 'Débito Visa',
|
||||
account_money: 'MercadoPago',
|
||||
bank_transfer: 'Transferencia',
|
||||
};
|
||||
|
||||
function formatCurrency(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(num);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function FacturasPendientesPage() {
|
||||
const { user } = useAuthStore();
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
const { data: payments, isLoading, error } = usePagosSinFactura();
|
||||
const emitir = useEmitirFacturaPago();
|
||||
const [emitiendoId, setEmitiendoId] = useState<string | null>(null);
|
||||
|
||||
if (!isGlobalAdmin) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Facturas Pendientes" />
|
||||
<main className="p-6">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="font-semibold">Acceso restringido</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Solo el administrador global puede consultar pagos sin factura.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleEmitir(paymentId: string) {
|
||||
setEmitiendoId(paymentId);
|
||||
try {
|
||||
await emitir.mutateAsync(paymentId);
|
||||
} finally {
|
||||
setEmitiendoId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Facturas Pendientes" />
|
||||
<main className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Pagos de suscripción sin factura
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||
Cargando...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-12 text-red-600">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
Error al cargar los pagos
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && payments && payments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<CheckCircle className="h-10 w-10 mb-3 text-green-500" />
|
||||
<p className="font-medium">No hay pagos pendientes de facturar</p>
|
||||
<p className="text-sm mt-1">Todos los pagos aprobados ya tienen su factura emitida.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && payments && payments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Cliente</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Plan</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Monto</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Método</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Fecha de pago</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-muted-foreground">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="py-3 px-2">
|
||||
<div className="font-medium">{p.tenant.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{p.tenant.rfc || '—'}</div>
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className="inline-flex items-center rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground ring-1 ring-inset ring-secondary/20">
|
||||
{PLAN_LABELS[p.subscription?.plan || 'custom'] || p.subscription?.plan || 'Custom'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 font-semibold">{formatCurrency(p.amount)}</td>
|
||||
<td className="py-3 px-2 text-muted-foreground">
|
||||
{METHOD_LABELS[p.paymentMethod || ''] || p.paymentMethod || '—'}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-muted-foreground">{formatDate(p.paidAt)}</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEmitir(p.id)}
|
||||
disabled={emitir.isPending && emitiendoId === p.id}
|
||||
>
|
||||
{emitir.isPending && emitiendoId === p.id ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
||||
Emitiendo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
Emitir factura
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx
Normal file
260
apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
|
||||
import { getTenants } from '@/lib/api/tenants';
|
||||
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface TenantOption {
|
||||
id: string;
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
plan: string;
|
||||
durationDays: number;
|
||||
status: string;
|
||||
token: string;
|
||||
sentAt: string;
|
||||
expiresAt: string;
|
||||
acceptedAt: string | null;
|
||||
tenant: {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function InvitacionesTrialPage() {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
const [durationDays, setDurationDays] = useState('30');
|
||||
const [plan, setPlan] = useState('business_control');
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tenantsData, invitationsData] = await Promise.all([
|
||||
getTenants(),
|
||||
getAllInvitations(),
|
||||
]);
|
||||
setTenants(tenantsData);
|
||||
setInvitations(invitationsData);
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedTenantId || !durationDays) {
|
||||
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await createInvitation({
|
||||
tenantId: selectedTenantId,
|
||||
plan,
|
||||
durationDays: parseInt(durationDays, 10),
|
||||
});
|
||||
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
|
||||
setSelectedTenantId('');
|
||||
setDurationDays('30');
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(id: string) {
|
||||
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
|
||||
try {
|
||||
await cancelInvitation(id);
|
||||
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return 'Pendiente';
|
||||
case 'accepted': return 'Aceptada';
|
||||
case 'expired': return 'Expirada';
|
||||
case 'cancelled': return 'Cancelada';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Gift className="h-6 w-6" />
|
||||
Invitaciones de Trial
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Envía invitaciones de prueba a despachos específicos</p>
|
||||
</div>
|
||||
|
||||
{/* Toast de resultado */}
|
||||
{message && (
|
||||
<div
|
||||
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
|
||||
message.kind === 'ok'
|
||||
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulario de creación */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Nueva invitación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Despacho</Label>
|
||||
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un despacho" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.nombre} ({t.rfc})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Select value={plan} onValueChange={setPlan}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business_control">Business Control</SelectItem>
|
||||
<SelectItem value="business_cloud">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Duración (días)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={durationDays}
|
||||
onChange={(e) => setDurationDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
|
||||
Enviar invitación
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabla de invitaciones */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : invitations.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-3">Despacho</th>
|
||||
<th className="text-left py-2 px-3">Plan</th>
|
||||
<th className="text-left py-2 px-3">Días</th>
|
||||
<th className="text-left py-2 px-3">Estado</th>
|
||||
<th className="text-left py-2 px-3">Enviado</th>
|
||||
<th className="text-left py-2 px-3">Expira</th>
|
||||
<th className="text-left py-2 px-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map((inv) => (
|
||||
<tr key={inv.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
|
||||
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
|
||||
</td>
|
||||
<td className="py-2 px-3">{inv.durationDays}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="flex items-center gap-1">
|
||||
{statusIcon(inv.status)}
|
||||
{statusLabel(inv.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(inv.id)}
|
||||
className="text-destructive hover:underline text-xs"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select,
|
||||
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
|
||||
@@ -43,9 +44,12 @@ export default function AdminUsuariosPage() {
|
||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGlobal) return;
|
||||
getTenants().then(setTenants).catch(console.error);
|
||||
}, []);
|
||||
}, [isGlobal]);
|
||||
|
||||
const handleEdit = (usuario: any) => {
|
||||
setEditingUser({
|
||||
|
||||
@@ -359,7 +359,7 @@ export default function CfdiPage() {
|
||||
|
||||
// CFDI Viewer state
|
||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<number | null>(null);
|
||||
|
||||
// Cancelación Facturapi state
|
||||
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
||||
@@ -367,10 +367,10 @@ export default function CfdiPage() {
|
||||
const [cancelSubstitution, setCancelSubstitution] = useState('');
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
const handleViewCfdi = async (id: string) => {
|
||||
const handleViewCfdi = async (id: number) => {
|
||||
setLoadingCfdi(id);
|
||||
try {
|
||||
const cfdi = await getCfdiById(id);
|
||||
const cfdi = await getCfdiById(String(id));
|
||||
setViewingCfdi(cfdi);
|
||||
} catch (error) {
|
||||
console.error('Error loading CFDI:', error);
|
||||
@@ -882,10 +882,10 @@ export default function CfdiPage() {
|
||||
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('¿Eliminar este CFDI?')) {
|
||||
try {
|
||||
await deleteCfdi.mutateAsync(id);
|
||||
await deleteCfdi.mutateAsync(String(id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting CFDI:', error);
|
||||
}
|
||||
@@ -920,9 +920,9 @@ export default function CfdiPage() {
|
||||
const calculateTotal = () => {
|
||||
const subtotal = formData.subtotal || 0;
|
||||
const descuento = formData.descuento || 0;
|
||||
const iva = formData.ivaTrasladoTraslado || 0;
|
||||
const iva = formData.ivaTraslado || 0;
|
||||
const isrRetencion = formData.isrRetencion || 0;
|
||||
const ivaRetencion = formData.ivaTrasladoRetencion || 0;
|
||||
const ivaRetencion = formData.ivaRetencion || 0;
|
||||
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
|
||||
import { usePagosSinFactura } from '@/lib/hooks/use-pagos-sin-factura';
|
||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react';
|
||||
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight, Receipt } from 'lucide-react';
|
||||
import type { Tenant } from '@/lib/api/tenants';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
|
||||
@@ -56,6 +57,7 @@ export default function ClientesPage() {
|
||||
queryFn: () => getClientesStats(from, to),
|
||||
enabled: !!user,
|
||||
});
|
||||
const { data: pagosSinFactura } = usePagosSinFactura();
|
||||
|
||||
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
|
||||
const usuariosPorTenant = useMemo(() => {
|
||||
@@ -129,14 +131,17 @@ export default function ClientesPage() {
|
||||
|
||||
const handleEdit = (tenant: Tenant) => {
|
||||
setEditingTenant(tenant);
|
||||
const sub = tenant.subscriptions?.[0];
|
||||
setFormData({
|
||||
nombre: tenant.nombre,
|
||||
rfc: tenant.rfc,
|
||||
plan: tenant.plan as PlanType,
|
||||
adminEmail: '',
|
||||
adminNombre: '',
|
||||
amount: 0,
|
||||
firstPaymentDueAt: '',
|
||||
amount: sub ? Number(sub.amount) : 0,
|
||||
firstPaymentDueAt: sub?.currentPeriodEnd
|
||||
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
|
||||
: '',
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
@@ -210,7 +215,7 @@ export default function ClientesPage() {
|
||||
</Card>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{/* Total clientes activos */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
@@ -286,6 +291,24 @@ export default function ClientesPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Facturas pendientes */}
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => router.push('/admin/facturas-pendientes')}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
|
||||
<Receipt className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pagosSinFactura?.length ?? 0}</p>
|
||||
<p className="text-sm text-muted-foreground">Facturas pendientes</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detalle de no renovaciones */}
|
||||
@@ -454,78 +477,78 @@ export default function ClientesPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Campos de admin y suscripción — solo al crear */}
|
||||
{/* Campos de admin — solo al crear */}
|
||||
{!editingTenant && (
|
||||
<>
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
|
||||
<Input
|
||||
id="adminNombre"
|
||||
value={formData.adminNombre}
|
||||
onChange={(e) => setFormData({ ...formData, adminNombre: e.target.value })}
|
||||
placeholder="Juan Pérez"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminEmail">Email del Dueño</Label>
|
||||
<Input
|
||||
id="adminEmail"
|
||||
type="email"
|
||||
value={formData.adminEmail}
|
||||
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
|
||||
placeholder="admin@empresa.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
|
||||
<Input
|
||||
id="adminNombre"
|
||||
value={formData.adminNombre}
|
||||
onChange={(e) => setFormData({ ...formData, adminNombre: e.target.value })}
|
||||
placeholder="Juan Pérez"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminEmail">Email del Dueño</Label>
|
||||
<Input
|
||||
id="adminEmail"
|
||||
type="email"
|
||||
value={formData.adminEmail}
|
||||
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
|
||||
placeholder="admin@empresa.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formData.plan !== 'trial' && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.amount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{formData.plan === 'custom' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta.
|
||||
Si pones >$0, se generará Subscription con preapproval MercadoPago mensual.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{formData.plan === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstPaymentDueAt">Primera fecha de pago</Label>
|
||||
<Input
|
||||
id="firstPaymentDueAt"
|
||||
type="date"
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
value={formData.firstPaymentDueAt}
|
||||
onChange={(e) => setFormData({ ...formData, firstPaymentDueAt: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline visible al cliente para realizar su primer pago. Opcional.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campos de suscripción — crear y editar (solo planes pagados / custom) */}
|
||||
{formData.plan !== 'trial' && (
|
||||
<div className="grid gap-4 md:grid-cols-2 border-t pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.amount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{formData.plan === 'custom' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta.
|
||||
Si pones >$0, se generará Subscription con preapproval MercadoPago mensual.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{formData.plan === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstPaymentDueAt">Primera fecha de pago</Label>
|
||||
<Input
|
||||
id="firstPaymentDueAt"
|
||||
type="date"
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
value={formData.firstPaymentDueAt}
|
||||
onChange={(e) => setFormData({ ...formData, firstPaymentDueAt: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline visible al cliente para realizar su primer pago. Opcional.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{formData.plan === 'trial' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{formData.plan === 'trial' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
|
||||
@@ -77,7 +77,7 @@ function RegimenesActivosSection() {
|
||||
|
||||
useEffect(() => {
|
||||
if (activos && catalogo) {
|
||||
const ids = new Set(activos.map(a => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]);
|
||||
const ids = new Set(activos.map((a: { clave: string }) => catalogo.find((c: { clave: string; id: number }) => c.clave === a.clave)?.id).filter(Boolean) as number[]);
|
||||
setSelected(ids);
|
||||
}
|
||||
}, [activos, catalogo]);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
|
||||
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard } from 'lucide-react';
|
||||
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard, Gift } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||
@@ -37,7 +38,7 @@ export default function PlanesDespachoPage() {
|
||||
const { user } = useAuthStore();
|
||||
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now'>(null);
|
||||
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now' | 'accept-invite'>(null);
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
// Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business
|
||||
// Control y Enterprise siempre se cobran anual. Default monthly para
|
||||
@@ -45,6 +46,12 @@ export default function PlanesDespachoPage() {
|
||||
// muestra como CTA secundario.
|
||||
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
|
||||
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
|
||||
const [pendingInvitation, setPendingInvitation] = useState<{
|
||||
id: string;
|
||||
plan: string;
|
||||
durationDays: number;
|
||||
token: string;
|
||||
} | null>(null);
|
||||
|
||||
const fetchPlan = () => {
|
||||
apiClient.get<PlanInfo>('/despachos/me/plan')
|
||||
@@ -58,6 +65,20 @@ export default function PlanesDespachoPage() {
|
||||
.then(res => setPlanInfo(res.data))
|
||||
.catch(() => setPlanInfo(null))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// Cargar invitación de trial pendiente
|
||||
getPendingInvitation()
|
||||
.then((inv) => {
|
||||
if (inv && inv.status === 'pending') {
|
||||
setPendingInvitation({
|
||||
id: inv.id,
|
||||
plan: inv.plan,
|
||||
durationDays: inv.durationDays,
|
||||
token: inv.token,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const currentPlan = planInfo?.plan ?? null;
|
||||
@@ -153,6 +174,22 @@ export default function PlanesDespachoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcceptInvitation() {
|
||||
if (!pendingInvitation) return;
|
||||
setBusy('accept-invite');
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await acceptInvitation(pendingInvitation.token);
|
||||
setMessage({ kind: 'ok', text: `¡Activado! Tienes ${result.durationDays} días de Business Control Prueba.` });
|
||||
setPendingInvitation(null);
|
||||
fetchPlan();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al activar la invitación' });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
return (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
||||
@@ -242,6 +279,28 @@ export default function PlanesDespachoPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner de invitación de trial pendiente */}
|
||||
{!loading && pendingInvitation && (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
|
||||
<Gift className="h-6 w-6 text-purple-600 dark:text-purple-400 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<div className="font-semibold text-purple-900 dark:text-purple-200">
|
||||
Invitación especial — Business Control Prueba
|
||||
</div>
|
||||
<div className="text-purple-700 dark:text-purple-400">
|
||||
Tienes una invitación para probar Business Control por <strong>{pendingInvitation.durationDays} días</strong> con todas las funciones.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAcceptInvitation}
|
||||
disabled={busy === 'accept-invite'}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{busy === 'accept-invite' ? 'Activando...' : 'Activar ahora'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner de suscripción activa */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
||||
const sub = planInfo.subscription;
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ContribuyentesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
|
||||
<Button
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@horux/shared-ui';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
|
||||
import { PeriodoSelector } from '@/components/periodo-selector';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
||||
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Despacho {
|
||||
id: string;
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
totalContribuyentes: number;
|
||||
ultimaExtraccion: string | null;
|
||||
@@ -20,14 +27,28 @@ interface Stats {
|
||||
tareasAtrasadas: number;
|
||||
}
|
||||
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
|
||||
export default function DespachoContribuyentesPage() {
|
||||
const role = useAuthStore(s => s.user?.role);
|
||||
const enabled = role === 'owner' || role === 'cfo';
|
||||
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
const enabled = role === 'owner' || role === 'cfo' || isPlatformStaff;
|
||||
const { fechaInicio } = usePeriodoStore();
|
||||
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
|
||||
const { viewingTenantId, setViewingTenant } = useTenantViewStore();
|
||||
|
||||
const { data: despachos } = useQuery<Despacho[]>({
|
||||
queryKey: ['admin-despachos'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<{ data: Despacho[] }>('/admin/dashboard/despachos');
|
||||
return data.data;
|
||||
},
|
||||
enabled: isPlatformStaff,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery<Stats>({
|
||||
queryKey: ['despacho-contribuyentes-stats', año, mes],
|
||||
queryKey: ['despacho-contribuyentes-stats', viewingTenantId, año, mes],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
|
||||
return data;
|
||||
@@ -56,6 +77,31 @@ export default function DespachoContribuyentesPage() {
|
||||
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
|
||||
<main className="p-6 max-w-7xl mx-auto">
|
||||
<DespachoSubnav />
|
||||
{isPlatformStaff && despachos && despachos.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Ver despacho
|
||||
</label>
|
||||
<Select
|
||||
value={viewingTenantId || ''}
|
||||
onValueChange={(value) => {
|
||||
const d = despachos.find((x) => x.id === value);
|
||||
setViewingTenant(value || null, d?.nombre || null, d?.rfc || null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-md">
|
||||
<SelectValue placeholder="Selecciona un despacho" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{despachos.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.nombre} ({d.rfc})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
|
||||
@@ -25,11 +25,14 @@ interface Asignado {
|
||||
tareasCompletadas: number;
|
||||
}
|
||||
|
||||
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
|
||||
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'contador', 'visor']);
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
|
||||
export default function MisAsignadosPage() {
|
||||
const role = useAuthStore(s => s.user?.role);
|
||||
const enabled = role ? ROLES_ASIGNADOS.has(role) : false;
|
||||
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
const enabled = role ? (ROLES_ASIGNADOS.has(role) || isPlatformStaff) : false;
|
||||
const { setSelectedContribuyente } = useContribuyenteStore();
|
||||
const { fechaInicio } = usePeriodoStore();
|
||||
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
|
||||
|
||||
@@ -8,9 +8,10 @@ import { defaultDespachoPathForRole } from '@/components/despachos/despacho-subn
|
||||
export default function DespachosIndex() {
|
||||
const router = useRouter();
|
||||
const role = useAuthStore(s => s.user?.role);
|
||||
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||
useEffect(() => {
|
||||
if (!role) return;
|
||||
router.replace(defaultDespachoPathForRole(role));
|
||||
}, [role, router]);
|
||||
router.replace(defaultDespachoPathForRole(role, platformRoles));
|
||||
}, [role, platformRoles, router]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function OnboardingPage() {
|
||||
title: 'Subir FIEL del contribuyente',
|
||||
description: 'Necesaria para sincronizar con el SAT.',
|
||||
icon: <Key className="h-5 w-5" />,
|
||||
href: '/contribuyentes',
|
||||
href: '/configuracion/sat',
|
||||
completed: fielDone,
|
||||
},
|
||||
{
|
||||
@@ -99,7 +99,7 @@ export default function OnboardingPage() {
|
||||
title: 'Subir CSD (para emitir facturas)',
|
||||
description: 'Certificado de Sello Digital para timbrado.',
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
href: '/contribuyentes',
|
||||
href: '/configuracion/csd',
|
||||
completed: csdDone,
|
||||
},
|
||||
{
|
||||
@@ -178,11 +178,11 @@ export default function OnboardingPage() {
|
||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
||||
</div>
|
||||
{!step.completed && step.href !== '#' && (
|
||||
<Link href={step.href}>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1" asChild>
|
||||
<Link href={step.href}>
|
||||
Configurar <ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { isDespachoTenant } from '@horux/shared';
|
||||
import type { Role } from '@horux/shared';
|
||||
import type { Role, UserInvite } from '@horux/shared';
|
||||
|
||||
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
|
||||
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||
@@ -83,10 +83,10 @@ export default function UsuariosPage() {
|
||||
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
|
||||
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: Role; supervisorUserId?: string }>({
|
||||
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
|
||||
email: '',
|
||||
nombre: '',
|
||||
role: defaultInviteRole as Role,
|
||||
role: defaultInviteRole as UserInvite['role'],
|
||||
});
|
||||
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function UsuariosPage() {
|
||||
);
|
||||
}
|
||||
setShowInvite(false);
|
||||
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined });
|
||||
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as UserInvite['role'], supervisorUserId: undefined });
|
||||
setSelectedRfcIds([]);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.message || 'Error al invitar usuario');
|
||||
@@ -211,11 +211,11 @@ export default function UsuariosPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && isDespacho && (
|
||||
<Link href="/carteras">
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<Button variant="outline" className="flex items-center gap-2" asChild>
|
||||
<Link href="/carteras">
|
||||
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button onClick={() => setShowInvite(true)}>
|
||||
@@ -263,13 +263,13 @@ export default function UsuariosPage() {
|
||||
<Label htmlFor="role">Rol</Label>
|
||||
<Select
|
||||
value={inviteForm.role}
|
||||
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as Role, supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
|
||||
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inviteRoles.map(r => (
|
||||
{inviteRoles.map((r: { value: string; label: string; description?: string }) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
<div className="flex flex-col">
|
||||
<span>{r.label}</span>
|
||||
|
||||
Reference in New Issue
Block a user