Initial commit - Horux Despachos NL
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
'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, DESPACHO_OVERAGE_PRICE_MENSUAL } from '@horux/shared';
|
||||
import { Tags, ShieldAlert, Info, AlertTriangle, Check, Loader2, Pencil, X } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
interface DespachoPlanLimits {
|
||||
plan: string;
|
||||
nombre: string;
|
||||
monthly: number | null;
|
||||
firstYear: number | null;
|
||||
renewal: number | null;
|
||||
permiteMonthly: boolean;
|
||||
maxRfcs: number;
|
||||
maxUsers: number;
|
||||
timbresIncluidosMes: number;
|
||||
dbMode: 'BYO' | 'MANAGED';
|
||||
permiteServidorBackup: boolean;
|
||||
permiteSatIncremental: boolean;
|
||||
}
|
||||
|
||||
const PLAN_ORDER = ['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud', 'custom'];
|
||||
|
||||
function fmtCurrency(n: number | null): string {
|
||||
if (n == null) return '—';
|
||||
return `$${n.toLocaleString('es-MX')}`;
|
||||
}
|
||||
|
||||
async function listDespachoCatalogo(): Promise<DespachoPlanLimits[]> {
|
||||
const res = await apiClient.get<{ data: DespachoPlanLimits[] }>('/planes/despacho');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
async function updateDespachoCatalogo(plan: string, patch: Partial<DespachoPlanLimits>): Promise<DespachoPlanLimits> {
|
||||
const res = await apiClient.patch<DespachoPlanLimits>(`/planes/despacho/${plan}`, patch);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export default function PreciosSuscripcionPage() {
|
||||
const { user } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
|
||||
const [editingPlan, setEditingPlan] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Partial<DespachoPlanLimits>>({});
|
||||
|
||||
const { data: plans = [], isLoading } = useQuery({
|
||||
queryKey: ['despacho-catalogo'],
|
||||
queryFn: listDespachoCatalogo,
|
||||
enabled: isGlobalAdmin,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ plan, patch }: { plan: string; patch: Partial<DespachoPlanLimits> }) =>
|
||||
updateDespachoCatalogo(plan, patch),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['despacho-catalogo'] });
|
||||
setEditingPlan(null);
|
||||
setDraft({});
|
||||
},
|
||||
});
|
||||
|
||||
if (!isGlobalAdmin) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Precios de suscripciones" />
|
||||
<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 consultar el catálogo de precios.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const startEdit = (p: DespachoPlanLimits) => {
|
||||
setEditingPlan(p.plan);
|
||||
setDraft({
|
||||
nombre: p.nombre,
|
||||
monthly: p.monthly,
|
||||
firstYear: p.firstYear,
|
||||
renewal: p.renewal,
|
||||
permiteMonthly: p.permiteMonthly,
|
||||
maxRfcs: p.maxRfcs,
|
||||
maxUsers: p.maxUsers,
|
||||
timbresIncluidosMes: p.timbresIncluidosMes,
|
||||
dbMode: p.dbMode,
|
||||
permiteServidorBackup: p.permiteServidorBackup,
|
||||
permiteSatIncremental: p.permiteSatIncremental,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingPlan(null);
|
||||
setDraft({});
|
||||
};
|
||||
|
||||
const saveEdit = (plan: string) => {
|
||||
updateMutation.mutate({ plan, patch: draft });
|
||||
};
|
||||
|
||||
const sorted = [...plans].sort((a, b) => PLAN_ORDER.indexOf(a.plan) - PLAN_ORDER.indexOf(b.plan));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Precios de suscripciones" />
|
||||
<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">
|
||||
<Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
Los planes despacho se almacenan en la tabla <code className="text-xs bg-muted px-1 py-0.5 rounded">despacho_plan_prices</code>.
|
||||
Puedes editar precios y limits desde aquí — los cambios aplican a contrataciones nuevas y renovaciones; las suscripciones vigentes
|
||||
conservan su precio. Las <strong>features</strong> de cada plan siguen versionadas en código.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Tags className="h-5 w-5" />
|
||||
Planes despacho
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
|
||||
Cargando catálogo…
|
||||
</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">Plan</th>
|
||||
<th className="py-2 pr-4 text-right">Mensual</th>
|
||||
<th className="py-2 pr-4 text-right">Anual 1°</th>
|
||||
<th className="py-2 pr-4 text-right">Renovación</th>
|
||||
<th className="py-2 pr-4 text-right">RFCs</th>
|
||||
<th className="py-2 pr-4 text-right">Usuarios</th>
|
||||
<th className="py-2 pr-4 text-right">Timbres</th>
|
||||
<th className="py-2 pr-4">DB</th>
|
||||
<th className="py-2 pr-4">Backup</th>
|
||||
<th className="py-2 pr-4">SAT Inc</th>
|
||||
<th className="py-2 pr-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((p) => {
|
||||
const editing = editingPlan === p.plan;
|
||||
return (
|
||||
<tr key={p.plan} className="border-b last:border-0 hover:bg-muted/40 align-middle">
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{editing ? (
|
||||
<input className="w-32 px-2 py-1 border rounded text-sm bg-background"
|
||||
value={draft.nombre ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, nombre: e.target.value })} />
|
||||
) : (
|
||||
<>{p.nombre} <span className="text-xs text-muted-foreground font-normal">({p.plan})</span></>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{editing ? (
|
||||
<input type="number" className="w-20 px-2 py-1 border rounded text-sm text-right bg-background"
|
||||
value={draft.monthly ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, monthly: e.target.value === '' ? null : Number(e.target.value) })} />
|
||||
) : (
|
||||
fmtCurrency(p.monthly)
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{editing ? (
|
||||
<input type="number" className="w-24 px-2 py-1 border rounded text-sm text-right bg-background"
|
||||
value={draft.firstYear ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, firstYear: e.target.value === '' ? null : Number(e.target.value) })} />
|
||||
) : (
|
||||
fmtCurrency(p.firstYear)
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{editing ? (
|
||||
<input type="number" className="w-24 px-2 py-1 border rounded text-sm text-right bg-background"
|
||||
value={draft.renewal ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, renewal: e.target.value === '' ? null : Number(e.target.value) })} />
|
||||
) : (
|
||||
fmtCurrency(p.renewal)
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{editing ? (
|
||||
<input type="number" className="w-16 px-2 py-1 border rounded text-sm text-right bg-background"
|
||||
value={draft.maxRfcs ?? 0}
|
||||
onChange={(e) => setDraft({ ...draft, maxRfcs: Number(e.target.value) })} />
|
||||
) : (
|
||||
p.maxRfcs
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{editing ? (
|
||||
<input type="number" className="w-16 px-2 py-1 border rounded text-sm text-right bg-background"
|
||||
value={draft.maxUsers ?? 0}
|
||||
onChange={(e) => setDraft({ ...draft, maxUsers: Number(e.target.value) })} />
|
||||
) : (
|
||||
p.maxUsers === -1 ? '∞' : p.maxUsers
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{editing ? (
|
||||
<input type="number" className="w-16 px-2 py-1 border rounded text-sm text-right bg-background"
|
||||
value={draft.timbresIncluidosMes ?? 0}
|
||||
onChange={(e) => setDraft({ ...draft, timbresIncluidosMes: Number(e.target.value) })} />
|
||||
) : (
|
||||
p.timbresIncluidosMes
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{editing ? (
|
||||
<select className="px-2 py-1 border rounded text-sm bg-background"
|
||||
value={draft.dbMode ?? 'MANAGED'}
|
||||
onChange={(e) => setDraft({ ...draft, dbMode: e.target.value as 'BYO' | 'MANAGED' })}>
|
||||
<option value="MANAGED">MANAGED</option>
|
||||
<option value="BYO">BYO</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-muted">{p.dbMode}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center">
|
||||
{editing ? (
|
||||
<input type="checkbox"
|
||||
checked={draft.permiteServidorBackup ?? false}
|
||||
onChange={(e) => setDraft({ ...draft, permiteServidorBackup: e.target.checked })} />
|
||||
) : (
|
||||
p.permiteServidorBackup ? <Check className="h-4 w-4 text-green-600 inline" /> : <span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center">
|
||||
{editing ? (
|
||||
<input type="checkbox"
|
||||
checked={draft.permiteSatIncremental ?? false}
|
||||
onChange={(e) => setDraft({ ...draft, permiteSatIncremental: e.target.checked })} />
|
||||
) : (
|
||||
p.permiteSatIncremental ? <Check className="h-4 w-4 text-green-600 inline" /> : <span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
{editing ? (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button size="sm" variant="default" onClick={() => saveEdit(p.plan)} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit} disabled={updateMutation.isPending}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={() => startEdit(p)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{updateMutation.isError && (
|
||||
<Card className="bg-red-50 dark:bg-red-950/30 border-red-200">
|
||||
<CardContent className="py-3 text-sm text-red-700 dark:text-red-400">
|
||||
Error guardando: {String((updateMutation.error as any)?.response?.data?.message || updateMutation.error)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-900/50">
|
||||
<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 className="space-y-1">
|
||||
<p>
|
||||
<strong>Cobro adicional por RFC extra:</strong>{' '}
|
||||
<span className="font-mono">${DESPACHO_OVERAGE_PRICE_MENSUAL}/mes</span> por
|
||||
cada contribuyente que exceda el límite. Solo aplica a
|
||||
<strong> Business Control</strong> y <strong>Enterprise</strong>; los planes
|
||||
Mi Empresa tienen límite duro de 1 RFC.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
<strong>maxUsers = -1</strong> significa ilimitado. <strong>trial</strong> y <strong>custom</strong> no tienen precio fijo
|
||||
(trial es gratis 30 días; custom se asigna con monto variable al provisionar).
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
|
||||
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
|
||||
vs ~24h con solo el daily.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user