feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico

Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 15:45:30 +02:00
parent eb1622c88c
commit 3425364b07
15 changed files with 1067 additions and 1011 deletions

View File

@@ -38,6 +38,7 @@ type DriveFile = {
bytes?: number | null;
modifiedTime?: string;
webViewLink?: string;
isFolder?: boolean;
};
function formatBytes(n?: number | null) {
@@ -89,6 +90,10 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
const [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
const [driveFolder, setDriveFolder] = useState<string | null>(null);
const [driveOpenUrl, setDriveOpenUrl] = useState<string | null>(null);
const [drivePath, setDrivePath] = useState<
{ id: string; name: string }[]
>([]);
const [warning, setWarning] = useState<string | null>(null);
const [folder, setFolder] = useState('');
const [loading, setLoading] = useState(true);
@@ -121,11 +126,15 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
}
}, []);
const loadDrive = useCallback(async () => {
const res = await fetch('/api/assets/drive');
const loadDrive = useCallback(async (folderId?: string) => {
const qs = folderId
? `?folderId=${encodeURIComponent(folderId)}`
: '';
const res = await fetch(`/api/assets/drive${qs}`);
const data = await res.json();
setDriveFiles(data.files || []);
setDriveFolder(data.folderName || null);
setDriveOpenUrl(data.driveOpenUrl || null);
if (data.warning) setWarning(data.warning);
else if (data.linked) setWarning(null);
}, []);
@@ -134,9 +143,12 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
setLoading(true);
setErr(null);
if (tab === 'portal') await loadPortal();
else await loadDrive();
else {
const current = drivePath[drivePath.length - 1]?.id;
await loadDrive(current);
}
setLoading(false);
}, [tab, loadPortal, loadDrive]);
}, [tab, loadPortal, loadDrive, drivePath]);
useEffect(() => {
void load();
@@ -190,6 +202,13 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
}
async function openDrive(file: DriveFile) {
if (file.isFolder || file.mimeType === 'application/vnd.google-apps.folder') {
setDrivePath((prev) => [...prev, { id: file.id, name: file.name }]);
setLoading(true);
await loadDrive(file.id);
setLoading(false);
return;
}
if (file.webViewLink) {
window.open(file.webViewLink, '_blank', 'noopener,noreferrer');
return;
@@ -205,6 +224,21 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
window.open(data.url, '_blank', 'noopener,noreferrer');
}
async function goDriveRoot() {
setDrivePath([]);
setLoading(true);
await loadDrive();
setLoading(false);
}
async function goDriveCrumb(index: number) {
const next = drivePath.slice(0, index + 1);
setDrivePath(next);
setLoading(true);
await loadDrive(next[next.length - 1]?.id);
setLoading(false);
}
async function remove(id: string) {
if (!confirm('¿Eliminar este archivo de la biblioteca?')) return;
const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, {
@@ -222,7 +256,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<PageHeader
eyebrow="Contenido"
title="Activos"
description={`Contenido de Drive de ${tenantLabel}. Pestaña Drive por defecto; Portal son archivos subidos al Centro de Mando.`}
description={`Réplica de la carpeta Drive del cliente (${tenantLabel}) bajo Clientes. Navega carpetas como en Drive; «Abrir en Drive» para la UI nativa.`}
/>
<div className="flex gap-2">
@@ -462,49 +496,88 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
{tab === 'drive' && (
<>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600">
<button
type="button"
onClick={() => void goDriveRoot()}
className="font-semibold text-brand-600 hover:underline"
>
{driveFolder || 'Carpeta cliente'}
</button>
{drivePath.map((p, i) => (
<span key={p.id} className="inline-flex items-center gap-2">
<span className="text-gray-400">/</span>
<button
type="button"
onClick={() => void goDriveCrumb(i)}
className="hover:underline text-gray-800"
>
{p.name}
</button>
</span>
))}
{driveOpenUrl && (
<a
href={driveOpenUrl}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 text-brand-600 hover:underline"
>
Abrir en Google Drive <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
<p className="text-[11px] text-gray-500">
Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}).
Embed iframe no aplica a carpetas privadas; listamos el contenido vía API.
</p>
{loading ? (
<p className="text-sm text-gray-500">Cargando Drive…</p>
) : driveFiles.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center text-gray-400">
<HardDrive className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-600">
Sin archivos en Drive
Carpeta vacía o sin enlace
</p>
<p className="text-xs text-gray-500 mt-2">
Conecta marketing@ en Agencia y ejecuta auto-match por nombre.
Conecta marketing@ en Agencia y ejecuta auto-match por nombre bajo
«Clientes».
</p>
</div>
) : (
<ul className="space-y-2">
{driveFiles.map((f) => (
<li
key={f.id}
className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3"
>
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
{iconHint(f.mimeType)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 font-medium truncate">
{f.name}
</p>
<p className="text-[11px] text-gray-500">
{formatBytes(f.bytes)}
{f.modifiedTime
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
: ''}
</p>
</div>
<button
type="button"
onClick={() => void openDrive(f)}
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
title="Abrir en Drive"
>
<ExternalLink className="w-4 h-4" />
</button>
</li>
))}
{driveFiles.map((f) => {
const folder =
f.isFolder ||
f.mimeType === 'application/vnd.google-apps.folder';
return (
<li key={f.id}>
<button
type="button"
onClick={() => void openDrive(f)}
className="w-full flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left hover:border-brand-300 hover:bg-brand-50/40"
>
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
{folder ? 'DIR' : iconHint(f.mimeType)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 font-medium truncate">
{f.name}
</p>
<p className="text-[11px] text-gray-500">
{folder
? 'Carpeta'
: formatBytes(f.bytes)}
{f.modifiedTime
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
: ''}
</p>
</div>
<ExternalLink className="w-4 h-4 text-gray-400 shrink-0" />
</button>
</li>
);
})}
</ul>
)}
</>