/* Tab Admin — panel de administración */ const PLAN_BADGE_COLORS = { starter: { bg: "rgba(96,165,250,0.15)", color: "#93c5fd" }, creator: { bg: "rgba(167,139,250,0.15)", color: "#c4b5fd" }, pro: { bg: "rgba(251,191,36,0.15)", color: "#fcd34d" }, admin: { bg: "rgba(248,113,113,0.15)", color: "#fca5a5" }, }; const PLAN_PRICES = { starter: 99, creator: 199, pro: 299 }; function PlanBadge({ plan, status }) { if (!plan) return Sin plan; const c = PLAN_BADGE_COLORS[plan] || PLAN_BADGE_COLORS.starter; return ( {plan} ); } function StatusDot({ status }) { const active = status === "active" || status === "trialing"; return ( ); } const IMAGE_SOUND_MAX_BYTES = 10 * 1024 * 1024; function formatSoundDuration(sec) { sec = Math.round(sec || 0); const m = Math.floor(sec / 60); const s = sec % 60; return `${m}:${String(s).padStart(2, "0")}`; } function ImageSoundsTab() { const [sounds, setSounds] = React.useState([]); const [loading, setLoading] = React.useState(true); const [uploading, setUploading] = React.useState(false); const [uploadError, setUploadError] = React.useState(""); const [playingId, setPlayingId] = React.useState(null); const audioRef = React.useRef(null); const fileInputRef = React.useRef(null); function fetchSounds() { setLoading(true); fetch("/api/image-sounds", { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setSounds(d.sounds || [])) .catch(() => {}) .finally(() => setLoading(false)); } React.useEffect(() => { fetchSounds(); }, []); function playPreview(url, id) { if (audioRef.current) audioRef.current.pause(); if (playingId === id) { setPlayingId(null); return; } const a = new Audio(url); audioRef.current = a; a.play().catch(() => {}); setPlayingId(id); a.onended = () => setPlayingId(null); } async function handleUpload(e) { const file = e.target.files && e.target.files[0]; e.target.value = ""; if (!file) return; setUploadError(""); if (file.size > IMAGE_SOUND_MAX_BYTES) { setUploadError("El sonido no debe superar 10MB"); return; } setUploading(true); try { const fd = new FormData(); fd.append("file", file); const r = await fetch("/api/image-sounds", { method: "POST", credentials: "same-origin", body: fd }); const d = await r.json(); if (r.ok && d.success) setSounds((prev) => [d.sound, ...prev]); else setUploadError(d.detail || "Error al subir el sonido"); } catch { setUploadError("Error de conexión"); } finally { setUploading(false); } } async function deleteSound(sound) { if (!confirm(`¿Borrar "${sound.original_name}"?`)) return; const r = await fetch(`/api/image-sounds/${sound.id}`, { method: "DELETE", credentials: "same-origin" }); if (r.ok) setSounds((prev) => prev.filter((s) => s.id !== sound.id)); } return (

Sonidos de imágenes

Biblioteca de sonidos para "FX Sonido en Imagen" (Hook Intro Imagen) — se usará próximamente en la generación de vídeos.

{uploadError && (
{uploadError}
)}
{loading &&
Cargando…
} {!loading && sounds.length === 0 && (
Todavía no hay sonidos. Máximo 10MB por sonido.
)} {!loading && sounds.length > 0 && (
{sounds.map((s) => (
{s.original_name}
{formatSoundDuration(s.duration)}
))}
)}
); } function AdminTab({ currentUser }) { const [view, setView] = React.useState("costs"); // "costs" | "users" | "image_sounds" | "music" const [users, setUsers] = React.useState([]); const [stats, setStats] = React.useState(null); const [loading, setLoading] = React.useState(true); const [editingUser, setEditingUser] = React.useState(null); const [editPlan, setEditPlan] = React.useState(""); const [editStatus, setEditStatus] = React.useState("active"); const [saving, setSaving] = React.useState(false); const [deletingId, setDeletingId] = React.useState(null); const [msg, setMsg] = React.useState(null); async function loadData() { setLoading(true); try { const [usersRes, statsRes] = await Promise.all([ fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/stats", { credentials: "same-origin" }), ]); if (usersRes.ok) setUsers((await usersRes.json()).users || []); if (statsRes.ok) setStats(await statsRes.json()); } catch (e) { setMsg({ type: "error", text: "Error al cargar datos" }); } finally { setLoading(false); } } React.useEffect(() => { loadData(); }, []); async function savePlan() { setSaving(true); try { const r = await fetch(`/api/admin/users/${editingUser.id}/plan`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ plan: editPlan || null, subscription_status: editStatus }), }); if (r.ok) { setMsg({ type: "success", text: `Plan de ${editingUser.username} actualizado` }); setEditingUser(null); loadData(); } else { const d = await r.json(); setMsg({ type: "error", text: d.detail || "Error al guardar" }); } } catch (e) { setMsg({ type: "error", text: "Error de conexión" }); } finally { setSaving(false); } } async function deleteUser(user) { const warn = `Vas a ELIMINAR a "${user.username}" y todos sus datos: avatares/canales, vídeos, ` + `música, accesos compartidos y sesiones.\n\nEsta acción NO se puede deshacer.`; if (!confirm(warn)) return; if (!confirm(`Confirma de nuevo: eliminar definitivamente a "${user.username}".`)) return; setDeletingId(user.id); setMsg(null); try { const r = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE", credentials: "same-origin", }); const d = await r.json().catch(() => ({})); if (r.ok) { setMsg({ type: "success", text: `Usuario "${user.username}" eliminado` }); setUsers((prev) => prev.filter((u) => u.id !== user.id)); loadData(); } else { setMsg({ type: "error", text: d.detail || "No se pudo eliminar el usuario" }); } } catch (e) { setMsg({ type: "error", text: "Error de conexión" }); } finally { setDeletingId(null); } } function openEdit(user) { setEditingUser(user); setEditPlan(user.plan || ""); setEditStatus(user.subscription_status || "active"); setMsg(null); } if (loading) return (
); return (
{view === "costs" ? : view === "image_sounds" ? : view === "music" ? : <> {msg && (
{msg.text}
)} {/* Stats row */} {stats && (
{[ { label: "Usuarios totales", value: stats.total_users }, { label: "Suscriptores activos", value: stats.active_subscribers }, { label: "MRR", value: `$${stats.mrr_eur}` }, ...Object.entries(stats.plan_distribution || {}).map(([p, n]) => ({ label: `Plan ${p}`, value: n, })), ].map((s, i) => (
{s.value}
{s.label}
))}
)} {/* Users table */}

Usuarios ({users.length})

{["Usuario", "Email", "Plan", "Estado", "Créditos (mes)", "Créditos (total)", "Ingreso mensual", "Registro", ""].map(h => ( ))} {users.map(u => { const creditsUsedMonth = u.credits_used_month != null ? u.credits_used_month : 0; const creditsMonthlyGrant = u.credits_monthly_grant != null ? u.credits_monthly_grant : 0; const creditsUsedTotal = u.credits_used_total != null ? u.credits_used_total : 0; const date = u.created_at ? new Date(u.created_at).toLocaleDateString("es-ES") : "—"; const monthlyRevenue = u.plan && PLAN_PRICES[u.plan] && u.subscription_status === "active" ? "$" + PLAN_PRICES[u.plan] : "—"; return ( ); })}
{h}
{u.is_admin && ADMIN} {u.username} {u.email || "—"}
{u.subscription_status || "—"}
{u.is_admin ? "∞" : creditsUsedMonth.toLocaleString() + " / " + creditsMonthlyGrant.toLocaleString()} {u.is_admin ? "∞" : creditsUsedTotal.toLocaleString()} {monthlyRevenue} {date} {!u.is_admin && (
{u.id !== currentUser?.id && ( )}
)}
{/* Edit plan modal */} {editingUser && (
{ if (e.target === e.currentTarget) setEditingUser(null); }}>

Editar plan — {editingUser.username}

)} }
); } /* ============================================ Gastos de APIs externas ============================================ */ const COST_PROVIDER_COLORS = { anthropic: "#a78bfa", nanobanana: "#4ade80", ltx: "#60a5fa", heygen: "#f472b6", lemonfox: "#fbbf24", elevenlabs: "#fb923c", minimax: "#34d399", motion: "#38bdf8", pexels: "#94a3b8", }; const COST_PROVIDER_LABELS = { anthropic: "Claude (Anthropic)", nanobanana: "NanoBanana", ltx: "LTX Video", heygen: "HeyGen", lemonfox: "Lemonfox (transcripción)", elevenlabs: "ElevenLabs / ai33 (TTS)", minimax: "Minimax (TTS)", motion: "Motion.so (infografía)", pexels: "Pexels (stock)", }; function fmtUSD(n) { const v = Number(n) || 0; return "$" + v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function CostBar({ label, value, max, color }) { const pct = max > 0 ? Math.max(2, Math.round((value / max) * 100)) : 0; return (
{label}
{fmtUSD(value)}
); } function lastNMonths(n) { const out = []; const now = new Date(); for (let i = 0; i < n; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; out.push({ id: ym, label: d.toLocaleDateString("es-ES", { month: "long", year: "numeric" }) }); } return out; } const MONTHS_ES = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]; function fmtUSDshort(n) { const v = Number(n) || 0; if (v === 0) return "$0"; if (v >= 1000) return "$" + (v / 1000).toFixed(1) + "k"; if (v >= 10) return "$" + v.toFixed(0); return "$" + v.toFixed(2); } function monthLabel(ym) { const [y, m] = (ym || "").split("-").map(Number); return `${MONTHS_ES[(m || 1) - 1]} ${y || ""}`; } function ProviderLegend({ providers }) { return (
{providers.map((p) => ( {COST_PROVIDER_LABELS[p] || p} ))}
); } /* Gráfica anual: barras apiladas por proveedor, un grupo por mes. Click = seleccionar mes. */ function AnnualCostChart({ byMonth, providers, selectedMonth, onSelectMonth }) { const W = 640, H = 210, padL = 44, padB = 24, padT = 10, padR = 8; const plotW = W - padL - padR, plotH = H - padT - padB; const max = Math.max(0.01, ...byMonth.map((m) => m.total)); const niceMax = (() => { const steps = [0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000]; return steps.find((s) => s >= max) || Math.ceil(max); })(); const bandW = plotW / 12; const barW = Math.min(26, bandW * 0.6); const y = (v) => padT + plotH - (v / niceMax) * plotH; const gridVals = [0, 0.25, 0.5, 0.75, 1].map((f) => niceMax * f); return (
{gridVals.map((gv, i) => ( {fmtUSDshort(gv)} ))} {byMonth.map((m, mi) => { const cx = padL + bandW * mi + bandW / 2; const isSel = m.month === selectedMonth; let acc = 0; return ( onSelectMonth && onSelectMonth(m.month)}> {isSel && ( )} {providers.map((p) => { const val = m.by_provider[p] || 0; if (val <= 0) return null; const h = (val / niceMax) * plotH; const yTop = y(acc + val); acc += val; return ; })} {MONTHS_ES[mi]} ); })}
); } /* Detalle diario del mes: una línea por proveedor + área del total. */ function DailyCostChart({ byDay, providers, ym }) { const [y, m] = ym.split("-").map(Number); const daysInMonth = new Date(y, m, 0).getDate(); const map = {}; byDay.forEach((d) => { map[Number(d.day.split("-")[2])] = d; }); const W = 640, H = 210, padL = 44, padB = 24, padT = 10, padR = 8; const plotW = W - padL - padR, plotH = H - padT - padB; const totals = []; for (let dd = 1; dd <= daysInMonth; dd++) totals.push(map[dd] ? map[dd].total : 0); const max = Math.max(0.01, ...totals); const niceMax = (() => { const steps = [0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]; return steps.find((s) => s >= max) || Math.ceil(max); })(); const px = (dd) => padL + (plotW * (dd - 1)) / Math.max(1, daysInMonth - 1); const py = (v) => padT + plotH - (v / niceMax) * plotH; const gridVals = [0, 0.25, 0.5, 0.75, 1].map((f) => niceMax * f); const ticks = [1, 5, 10, 15, 20, 25, 30].filter((t) => t <= daysInMonth); const areaPts = totals.map((v, i) => `${px(i + 1)},${py(v)}`).join(" "); const areaPath = `${padL},${py(0)} ${areaPts} ${px(daysInMonth)},${py(0)}`; return (
{gridVals.map((gv, i) => ( {fmtUSDshort(gv)} ))} {providers.map((p) => { const pts = []; for (let dd = 1; dd <= daysInMonth; dd++) { const v = map[dd] ? (map[dd].by_provider[p] || 0) : 0; pts.push(`${px(dd)},${py(v)}`); } return ; })} {ticks.map((t) => ( {t} ))}
); } function MonthPicker({ year, byMonth, selectedMonth, onSelect }) { const totals = {}; byMonth.forEach((m) => { totals[m.month] = m.total; }); return (
{MONTHS_ES.map((lbl, i) => { const ym = `${year}-${String(i + 1).padStart(2, "0")}`; const total = totals[ym] || 0; const isSel = ym === selectedMonth; return ( ); })}
); } function SpendCalendar({ ym, byDay }) { const [y, m] = ym.split("-").map(Number); const map = {}; byDay.forEach((d) => { map[Number(d.day.split("-")[2])] = d.total; }); const daysInMonth = new Date(y, m, 0).getDate(); const firstDow = (new Date(y, m - 1, 1).getDay() + 6) % 7; // 0 = Lunes const maxDay = Math.max(0.01, ...Object.values(map)); const cells = []; for (let i = 0; i < firstDow; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); return (
{["L", "M", "X", "J", "V", "S", "D"].map((d) => (
{d}
))}
{cells.map((d, i) => { if (d === null) return
; const total = map[d] || 0; const intensity = total > 0 ? 0.12 + 0.5 * (total / maxDay) : 0; return (
0 ? "rgba(124,92,255,0.35)" : "var(--border)"), background: total > 0 ? `rgba(124,92,255,${intensity.toFixed(3)})` : "var(--bg-card-2)", display: "flex", flexDirection: "column", justifyContent: "space-between", }}> {d} 0 ? "var(--text)" : "var(--text-4)" }}> {total > 0 ? fmtUSD(total) : "—"}
); })}
); } function AdminCostsTab() { const [summary, setSummary] = React.useState(null); const [byVideo, setByVideo] = React.useState([]); const [byChannel, setByChannel] = React.useState([]); const [byProviderOp, setByProviderOp] = React.useState([]); const [allChannels, setAllChannels] = React.useState([]); const [logRows, setLogRows] = React.useState([]); const [logTotal, setLogTotal] = React.useState(0); const [logProvider, setLogProvider] = React.useState(""); const [logOffset, setLogOffset] = React.useState(0); const [filterChannel, setFilterChannel] = React.useState(""); const [filterMonth, setFilterMonth] = React.useState(""); const LOG_PAGE = 25; const [loading, setLoading] = React.useState(true); const _now = new Date(); const [year, setYear] = React.useState(_now.getFullYear()); const [selMonth, setSelMonth] = React.useState( `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}` ); const [breakdown, setBreakdown] = React.useState(null); const monthOptions = lastNMonths(12); React.useEffect(() => { // Lista de canales sin filtrar (para las opciones del selector) — independiente del // resultado filtrado que se usa para el gráfico "Gasto por canal". fetch("/api/admin/costs/by-channel", { credentials: "same-origin" }).then(r => r.json()) .then(d => setAllChannels((d.channels || []).map(c => ({ id: c.channel_id, name: c.channel_name })))) .catch(() => setAllChannels([])); }, []); async function loadAll() { setLoading(true); try { const commonQs = new URLSearchParams(); if (filterChannel) commonQs.set("channel_id", filterChannel); if (filterMonth) commonQs.set("month", filterMonth); const summaryQs = new URLSearchParams(commonQs); summaryQs.set("months", "6"); const videoQs = new URLSearchParams(commonQs); videoQs.set("limit", "50"); const [s, v, c, p] = await Promise.all([ fetch(`/api/admin/costs/summary?${summaryQs.toString()}`, { credentials: "same-origin" }).then(r => r.json()), fetch(`/api/admin/costs/by-video?${videoQs.toString()}`, { credentials: "same-origin" }).then(r => r.json()), fetch(`/api/admin/costs/by-channel?${commonQs.toString()}`, { credentials: "same-origin" }).then(r => r.json()), fetch(`/api/admin/costs/by-provider?${commonQs.toString()}`, { credentials: "same-origin" }).then(r => r.json()), ]); setSummary(s); setByVideo(v.videos || []); setByChannel(c.channels || []); setByProviderOp(p.providers || []); } catch (e) { /* ignore */ } setLoading(false); } async function loadLog() { const qs = new URLSearchParams({ limit: LOG_PAGE, offset: logOffset }); if (logProvider) qs.set("provider", logProvider); if (filterChannel) qs.set("channel_id", filterChannel); if (filterMonth) qs.set("month", filterMonth); try { const r = await fetch(`/api/admin/costs/log?${qs.toString()}`, { credentials: "same-origin" }); const d = await r.json(); setLogRows(d.rows || []); setLogTotal(d.total || 0); } catch (e) { /* ignore */ } } async function loadBreakdown() { const qs = new URLSearchParams({ year: String(year), month: selMonth }); if (filterChannel) qs.set("channel_id", filterChannel); try { const r = await fetch(`/api/admin/costs/breakdown?${qs.toString()}`, { credentials: "same-origin" }); setBreakdown(await r.json()); } catch (e) { /* ignore */ } } React.useEffect(() => { loadAll(); }, [filterChannel, filterMonth]); React.useEffect(() => { loadBreakdown(); }, [year, selMonth, filterChannel]); React.useEffect(() => { loadLog(); }, [logProvider, logOffset, filterChannel, filterMonth]); React.useEffect(() => { setLogOffset(0); }, [filterChannel, filterMonth]); if (loading) return (
); const monthMax = Math.max(1, ...(summary?.by_month || []).map(m => m.total)); const providerTotals = summary?.by_provider || []; const providerMax = Math.max(1, ...providerTotals.map(p => p.total)); const videoMax = Math.max(1, ...byVideo.map(v => v.total)); const channelMax = Math.max(1, ...byChannel.map(c => c.total)); return (
Los costes son estimaciones calculadas con precios configurables por unidad (tokens, imágenes, minutos, caracteres), no facturas reales de cada proveedor. Ajusta las tarifas en src/cost_tracker.py según tus contratos reales. El registro de gastos se conserva indefinidamente (mínimo 1 año), aunque el vídeo o el canal se borren después.
{/* Filtros: canal + mes */}
{(filterChannel || filterMonth) && ( )}
{/* Stats row */}
{fmtUSD(summary?.total_this_month)}
Gasto este mes
{fmtUSD(summary?.total_all_time)}
Gasto total
{byVideo.length}
Vídeos con coste registrado
{providerTotals[0] ? COST_PROVIDER_LABELS[providerTotals[0].provider] || providerTotals[0].provider : "—"}
Mayor gasto por API
{/* ── Vista visual: resumen anual + detalle diario ── */}

Resumen anual {year}

{breakdown ? ( <> { setYear(Number(ym.split("-")[0])); setSelMonth(ym); }}/>
) :
Cargando…
}

Detalle diario — {monthLabel(selMonth)}

{!breakdown ? (
Cargando…
) : breakdown.by_day.length === 0 ? (
Sin gasto registrado este mes.
) : ( <>
)}
{/* ── Selector de mes + calendario de gasto por día ── */}

Seleccionar mes

{breakdown ? :
Cargando…
}

Calendario de gastos — {monthLabel(selMonth)}

{breakdown ? :
Cargando…
}
{/* Gasto por API — mes seleccionado */}

Gasto por API — {monthLabel(selMonth)}

{fmtUSD(breakdown?.month_total)}
{!breakdown || breakdown.month_by_provider.length === 0 ? (
Sin datos este mes.
) : breakdown.month_by_provider.map(p => (
{COST_PROVIDER_LABELS[p.provider] || p.provider} {fmtUSD(p.total)}
))}
{/* Gasto por canal */}

Gasto por canal

{byChannel.length === 0 ? (
Sin datos todavía.
) : byChannel.slice(0, 8).map(c => ( ))}
{/* Gasto por vídeo */}

Coste por vídeo

{["Vídeo", "Canal", "Llamadas", "Coste"].map(h => ( ))} {byVideo.length === 0 && ( )} {byVideo.map(v => ( ))}
{h}
Sin datos todavía.
{v.title} {v.channel_name} {v.calls} {fmtUSD(v.total)}
{/* Gasto por operación (desglose por API) */}

Desglose por API y operación

{["API", "Operación", "Llamadas", "Coste"].map(h => ( ))} {byProviderOp.length === 0 && ( )} {byProviderOp.map((r, i) => ( ))}
{h}
Sin datos todavía.
{COST_PROVIDER_LABELS[r.provider] || r.provider} {r.operation} {r.calls} {fmtUSD(r.total)}
{/* Log completo */}

Log de gastos ({logTotal})

{["Fecha", "API", "Operación", "Vídeo", "Canal", "Unidades", "Coste"].map(h => ( ))} {logRows.length === 0 && ( )} {logRows.map(r => ( ))}
{h}
Sin resultados.
{r.created_at ? new Date(r.created_at + "Z").toLocaleString("es-ES") : "—"} {COST_PROVIDER_LABELS[r.provider] || r.provider} {r.operation} {r.job_title || "—"} {r.channel_name || "—"} {r.units != null ? `${Math.round(r.units * 100) / 100} ${r.unit_label || ""}` : "—"} {fmtUSD(r.cost_usd)}
{logOffset + 1}–{Math.min(logOffset + LOG_PAGE, logTotal)} de {logTotal}
); } Object.assign(window, { AdminTab, // Reutilizados por la vista de gastos propia del usuario (tab_billing.jsx) AnnualCostChart, DailyCostChart, MonthPicker, SpendCalendar, ProviderLegend, COST_PROVIDER_COLORS, COST_PROVIDER_LABELS, costFmtUSD: fmtUSD, costMonthLabel: monthLabel, });