Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { apiClient } from '@/lib/api/client';
import { Package, ShieldAlert, Pencil, Loader2, Check as CheckIcon, X as XIcon, AlertTriangle, Power, PowerOff } from 'lucide-react';
interface AddonItem {
id: string;
codename: string;
nombre: string;
verticalProfile: string | null;
precio: number;
frecuencia: string;
active: boolean;
delta: unknown;
createdAt: string;
suscripcionesActivas: number;
}
async function listAddons(): Promise<AddonItem[]> {
const res = await apiClient.get<{ data: AddonItem[] }>('/admin/addons/catalogo');
return res.data.data;
}
async function updateAddon(id: string, data: { nombre?: string; precio?: number; active?: boolean }): Promise<void> {
await apiClient.put(`/admin/addons/catalogo/${id}`, data);
}
export default function AddonsPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const queryClient = useQueryClient();
const { data: addons = [], isLoading } = useQuery({
queryKey: ['admin-addons-catalogo'],
queryFn: listAddons,
enabled: isGlobalAdmin,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { nombre?: string; precio?: number; active?: boolean } }) => updateAddon(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-addons-catalogo'] }),
});
const [editing, setEditing] = useState<{ id: string; nombre: string; precio: string } | null>(null);
if (!isGlobalAdmin) {
return (
<>
<Header title="Add-ons del catálogo" />
<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 admin global puede modificar el catálogo de add-ons.</p>
</CardContent>
</Card>
</main>
</>
);
}
const startEdit = (item: AddonItem) => {
setEditing({ id: item.id, nombre: item.nombre, precio: String(item.precio) });
};
const cancelEdit = () => setEditing(null);
const saveEdit = async () => {
if (!editing) return;
const precio = Number(editing.precio);
if (!Number.isFinite(precio) || precio < 0) {
alert('El precio debe ser un número no negativo');
return;
}
if (!editing.nombre.trim()) {
alert('El nombre no puede estar vacío');
return;
}
try {
await updateMutation.mutateAsync({
id: editing.id,
data: { nombre: editing.nombre.trim(), precio },
});
setEditing(null);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al guardar');
}
};
const toggleActive = async (item: AddonItem) => {
if (item.active && item.suscripcionesActivas > 0) {
const confirmar = confirm(
`Hay ${item.suscripcionesActivas} suscripción(es) activa(s) usando este add-on. ` +
`Desactivarlo evitará nuevas contrataciones, pero las existentes siguen vigentes hasta su cancelación. ¿Continuar?`,
);
if (!confirmar) return;
}
try {
await updateMutation.mutateAsync({ id: item.id, data: { active: !item.active } });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cambiar estado');
}
};
return (
<>
<Header title="Add-ons del catálogo" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<p>
Los cambios de precio aplican <strong>a contrataciones nuevas</strong>. Las
suscripciones de add-on vigentes conservan el precio al que se cobraron.
</p>
<p className="text-muted-foreground mt-1">
Desactivar un add-on lo oculta del catálogo público, pero las suscripciones
activas siguen funcionando hasta su cancelación.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-5 w-5" />
Catálogo de add-ons
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-sm text-muted-foreground py-4">Cargando catálogo...</div>
) : addons.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">Sin add-ons configurados.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Codename</th>
<th className="py-2 pr-4">Nombre</th>
<th className="py-2 pr-4 text-right">Precio (MXN)</th>
<th className="py-2 pr-4">Frecuencia</th>
<th className="py-2 pr-4 text-right">Suscripciones activas</th>
<th className="py-2 pr-4">Estado</th>
<th className="py-2 pr-4"></th>
</tr>
</thead>
<tbody>
{addons.map(item => {
const isEditing = editing?.id === item.id;
return (
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 font-mono text-xs text-muted-foreground">{item.codename}</td>
<td className="py-3 pr-4">
{isEditing ? (
<Input
value={editing.nombre}
onChange={(e) => setEditing({ ...editing, nombre: e.target.value })}
className="h-8"
autoFocus
/>
) : (
<span className="font-medium">{item.nombre}</span>
)}
</td>
<td className="py-3 pr-4 text-right">
{isEditing ? (
<Input
type="number"
step="1"
min="0"
value={editing.precio}
onChange={(e) => setEditing({ ...editing, precio: e.target.value })}
className="h-8 w-32 text-right"
/>
) : (
<span className="font-medium">${item.precio.toLocaleString('es-MX')}</span>
)}
</td>
<td className="py-3 pr-4 text-muted-foreground">{item.frecuencia}</td>
<td className="py-3 pr-4 text-right">
<span className={item.suscripcionesActivas > 0 ? 'font-medium' : 'text-muted-foreground'}>
{item.suscripcionesActivas}
</span>
</td>
<td className="py-3 pr-4">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
item.active
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
: 'bg-muted text-muted-foreground'
}`}>
{item.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="py-3 pr-4">
<div className="flex items-center justify-end gap-1">
{isEditing ? (
<>
<Button
size="icon"
variant="ghost"
onClick={saveEdit}
disabled={updateMutation.isPending}
title="Guardar"
>
{updateMutation.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
: <CheckIcon className="h-4 w-4 text-green-600" />}
</Button>
<Button size="icon" variant="ghost" onClick={cancelEdit} title="Cancelar">
<XIcon className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
size="icon"
variant="ghost"
onClick={() => startEdit(item)}
title="Editar nombre y precio"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => toggleActive(item)}
disabled={updateMutation.isPending}
title={item.active ? 'Desactivar' : 'Activar'}
>
{item.active
? <PowerOff className="h-3.5 w-3.5 text-red-600" />
: <Power className="h-3.5 w-3.5 text-green-600" />}
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}