feat: sync Clientify CRM + botón Sincronizar por pestaña

El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-30 15:34:40 +02:00
parent c11ec20f26
commit e3180ceb51
13 changed files with 749 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ import {
CreativeZoomModal,
} from '@/components/AdCreativesGallery';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
type PlatformKpis = {
@@ -688,6 +689,7 @@ export default function AdTrackerClient({
eyebrow={`Ads · ${label}`}
title={tagline || 'Meta + Google Ads'}
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
actions={<SyncNowButton forceModule="ads" />}
/>
{/* Tabs */}

View File

@@ -13,6 +13,7 @@ import {
Upload,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
type Asset = {
id: string;
@@ -257,6 +258,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
eyebrow="Contenido"
title="Activos"
description={`Réplica exacta de Drive · carpeta del cliente «${tenantLabel}» (bajo Clientes). Solo ese árbol; sin mezclar otros clientes.`}
actions={<SyncNowButton forceModule="activos" />}
/>
<div className="flex gap-2">

View File

@@ -25,6 +25,7 @@ import {
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
import SyncNowButton from '@/components/SyncNowButton';
function rateClass(rate: number): string {
if (rate >= 8) return 'text-emerald-400';
@@ -106,6 +107,7 @@ export default function CrmDashboard({
eyebrow="Rendimiento"
title="CRM"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
actions={<SyncNowButton forceModule="crm" />}
/>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">

View File

@@ -16,6 +16,7 @@ import {
formatNum,
resolveKpiDayRange,
} from '@/lib/metrics';
import SyncNowButton from '@/components/SyncNowButton';
type RangeKey = KpiRangeKey;
@@ -85,7 +86,8 @@ export default function CrmPortfolioBoard({
cliente en Contexto para el detalle completo.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap justify-end">
<SyncNowButton forceModule="crm" />
<CalendarRange className="w-4 h-4 text-gray-500" />
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
{(

View File

@@ -26,6 +26,7 @@ import TenantSwitcher from '@/components/TenantSwitcher';
import type { DashboardModuleId } from '@/lib/tenant-modules';
import DateRangePicker from '@/components/DateRangePicker';
import { useDateRange } from '@/components/DateRangeContext';
import SyncNowButton from '@/components/SyncNowButton';
type NavItem = {
href: string;
@@ -348,14 +349,19 @@ export default function DashboardShell({
/>
</div>
</div>
<DateRangePicker
compact
preset={dateRange.preset}
customSince={dateRange.customSince}
customUntil={dateRange.customUntil}
onPreset={dateRange.setPreset}
onCustomChange={dateRange.setCustom}
/>
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<DateRangePicker
compact
preset={dateRange.preset}
customSince={dateRange.customSince}
customUntil={dateRange.customUntil}
onPreset={dateRange.setPreset}
onCustomChange={dateRange.setCustom}
/>
</div>
{isAgency ? <SyncNowButton /> : null}
</div>
</header>
<main className="flex-1 overflow-y-auto">

View File

@@ -35,6 +35,7 @@ import {
KpiWeeklyRow,
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
type Card = {
@@ -386,6 +387,7 @@ export default function MetricsDashboard({
eyebrow="Rendimiento"
title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
actions={<SyncNowButton forceModule="metrics" />}
/>
{sparse && (

View File

@@ -34,6 +34,7 @@ import {
Sparkles,
} from 'lucide-react';
import { PageHeader, SegmentedControl } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
type Props = {
label: string;
@@ -406,6 +407,7 @@ export default function OrganicoDashboard({
eyebrow="Crecimiento"
title="Orgánico"
description="SEO + GEO (visibilidad en IA). Elige un cliente en Contexto."
actions={<SyncNowButton forceModule="organico" />}
/>
<p className="text-sm text-gray-600 border border-gray-200 rounded-xl px-4 py-8 text-center bg-gray-50">
Selecciona un cliente para abrir su suite SEO / GEO.
@@ -430,6 +432,7 @@ export default function OrganicoDashboard({
description={`${label} · Observa posicionamiento en búsqueda e IA, competencia y oportunidades de contenido.`}
actions={
<div className="flex flex-wrap items-center gap-2">
<SyncNowButton forceModule="organico" />
<SegmentedControl
value={pillar}
onChange={(p) => setPillar(p)}

View File

@@ -14,6 +14,7 @@ import {
Send,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import NoteRichEditor, { richTextHasContent } from '@/components/NoteRichEditor';
type Entry = {
@@ -205,6 +206,7 @@ export default function SeguimientoBoard() {
eyebrow="Operación"
title="Seguimiento"
description={`${data.label} · histórico de notas (más reciente primero). El periodo del header no filtra este timeline.`}
actions={<SyncNowButton forceModule="seguimiento" />}
/>
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">

View File

@@ -17,6 +17,7 @@ import {
Repeat2,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
type Integ = {
@@ -571,6 +572,7 @@ export default function SocialDashboard({
eyebrow={`Social · ${tenantLabel}`}
title="Redes sociales"
description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`}
actions={<SyncNowButton forceModule="social" />}
/>
{err && (

View File

@@ -0,0 +1,153 @@
'use client';
import React, { useMemo, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { Loader2, RefreshCw } from 'lucide-react';
type SyncModule =
| 'metrics'
| 'crm'
| 'seguimiento'
| 'ads'
| 'social'
| 'organico'
| 'activos'
| 'all';
function moduleFromPath(pathname: string): SyncModule | null {
if (pathname.startsWith('/dashboard/agencia')) return 'all';
if (pathname.startsWith('/dashboard/kpis')) return 'metrics';
if (pathname.startsWith('/dashboard/crm')) return 'crm';
if (pathname.startsWith('/dashboard/seguimiento')) return 'seguimiento';
if (pathname.startsWith('/dashboard/ad-tracker')) return 'ads';
if (pathname.startsWith('/dashboard/social')) return 'social';
if (pathname.startsWith('/dashboard/organico')) return 'organico';
if (pathname.startsWith('/dashboard/activos')) return 'activos';
if (pathname.startsWith('/dashboard/estrategia')) return 'metrics';
if (pathname.startsWith('/dashboard/calendario')) return 'seguimiento';
return null;
}
const LABELS: Record<SyncModule, string> = {
metrics: 'Sincronizar métricas',
crm: 'Sincronizar CRM',
seguimiento: 'Sincronizar seguimiento',
ads: 'Sincronizar Ads',
social: 'Actualizar Social',
organico: 'Sincronizar orgánico',
activos: 'Sincronizar Drive',
all: 'Sincronizar todo',
};
export default function SyncNowButton({
forceModule,
className = '',
}: {
forceModule?: SyncModule;
className?: string;
}) {
const pathname = usePathname() || '';
const router = useRouter();
const module = forceModule || moduleFromPath(pathname);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const label = useMemo(
() => (module ? LABELS[module] : 'Sincronizar'),
[module]
);
if (!module) return null;
async function run() {
setBusy(true);
setMsg(null);
setErr(null);
try {
if (module === 'social') {
router.refresh();
setMsg('Social actualizado');
setBusy(false);
return;
}
const res = await fetch('/api/admin/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module, days: 14 }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || 'Sync falló');
setBusy(false);
return;
}
const parts: string[] = [];
if (data.crm?.results) {
const ok = (data.crm.results as { leads?: number; error?: string }[]).filter(
(r) => !r.error && (r.leads || 0) >= 0
);
const bad = (data.crm.results as { error?: string }[]).filter((r) => r.error);
parts.push(`CRM ${ok.length} ok` + (bad.length ? ` · ${bad.length} sin key` : ''));
}
if (data.ads) {
parts.push(
data.ads.ok === false
? `Ads error`
: `Ads ${data.ads.synced || 0} tenants`
);
}
if (data.organico) {
parts.push(
data.organico.ok === false ? 'Orgánico avisó' : 'Orgánico OK'
);
}
if (data.seguimiento) {
parts.push(
data.seguimiento.ok === false ? 'Seguimiento avisó' : 'Seguimiento OK'
);
}
if (data.activos) {
parts.push(
data.activos.ok === false
? 'Drive avisó'
: `Drive ${data.activos.linked ?? data.activos.matched ?? 0}`
);
}
setMsg(parts.join(' · ') || 'Listo');
router.refresh();
} catch {
setErr('Error de red');
}
setBusy(false);
}
return (
<div className={`flex flex-col items-end gap-0.5 ${className}`}>
<button
type="button"
disabled={busy}
onClick={() => void run()}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-800 hover:border-brand-400 hover:text-brand-700 disabled:opacity-50"
title="Actualiza datos de esta pestaña desde las APIs (agencia)"
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
{busy ? 'Sincronizando…' : label}
</button>
{msg ? (
<span className="text-[10px] text-emerald-700 max-w-[220px] text-right">
{msg}
</span>
) : null}
{err ? (
<span className="text-[10px] text-red-700 max-w-[220px] text-right">
{err}
</span>
) : null}
</div>
);
}