Initial commit: Horux Despachos project
This commit is contained in:
258
apps/web/app/(dashboard)/configuracion/addons/page.tsx
Normal file
258
apps/web/app/(dashboard)/configuracion/addons/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user