Ship monthly goals, calendar creative zoom, and legal pages.
Editable targets vs actual, pipeline review-only with client reject notes, and published pieces opening from the calendar day. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calculator, ShieldCheck, Sparkles, TrendingUp } from 'lucide-react';
|
||||
import { HelpDetails, SectionHead } from '@/components/dash-ui';
|
||||
|
||||
type SimBand = {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
|
||||
type SimPayload = {
|
||||
tenant_label?: string;
|
||||
hint?: string;
|
||||
target_channel?: string;
|
||||
baseline?: {
|
||||
historical_cpl?: number;
|
||||
@@ -13,27 +20,11 @@ type SimPayload = {
|
||||
avg_ticket?: number;
|
||||
sample_spend?: number;
|
||||
sample_leads?: number;
|
||||
sample_deals?: number;
|
||||
source?: string;
|
||||
};
|
||||
conservative_p10?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
expected_p50?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
optimistic_p90?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
conservative_p10?: SimBand;
|
||||
expected_p50?: SimBand;
|
||||
optimistic_p90?: SimBand;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -43,6 +34,13 @@ const CHANNELS = [
|
||||
{ id: 'blended', label: 'Blended' },
|
||||
] as const;
|
||||
|
||||
function euro(v: number) {
|
||||
return Number(v || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export default function BudgetSimulator({
|
||||
tenantLabel,
|
||||
}: {
|
||||
@@ -50,33 +48,37 @@ export default function BudgetSimulator({
|
||||
}) {
|
||||
const [spend, setSpend] = useState<number>(1000);
|
||||
const [channel, setChannel] = useState<string>('blended');
|
||||
const [simulation, setSimulation] = useState<SimPayload | null>(null);
|
||||
const [byChannel, setByChannel] = useState<Record<string, SimPayload>>({});
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
void fetchSimulation(spend, channel);
|
||||
}, 200);
|
||||
void fetchAll(spend);
|
||||
}, 220);
|
||||
return () => clearTimeout(timer);
|
||||
}, [spend, channel]);
|
||||
}, [spend]);
|
||||
|
||||
async function fetchSimulation(amount: number, ch: string) {
|
||||
async function fetchAll(amount: number) {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ spend: amount, channel: ch }),
|
||||
});
|
||||
const data = (await res.json()) as SimPayload;
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al simular');
|
||||
setSimulation(null);
|
||||
return;
|
||||
}
|
||||
setSimulation(data);
|
||||
const entries = await Promise.all(
|
||||
CHANNELS.map(async (ch) => {
|
||||
const res = await fetch('/api/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ spend: amount, channel: ch.id }),
|
||||
});
|
||||
const data = (await res.json()) as SimPayload;
|
||||
return [ch.id, res.ok ? data : { error: data.error }] as const;
|
||||
})
|
||||
);
|
||||
const next: Record<string, SimPayload> = {};
|
||||
for (const [id, data] of entries) next[id] = data;
|
||||
const firstErr = entries.find(([, d]) => d.error)?.[1].error;
|
||||
if (firstErr && !next.blended?.expected_p50) setErr(firstErr);
|
||||
setByChannel(next);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
@@ -84,28 +86,21 @@ export default function BudgetSimulator({
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = byChannel[channel] || null;
|
||||
const p10 = simulation?.conservative_p10 || {};
|
||||
const p50 = simulation?.expected_p50 || {};
|
||||
const p90 = simulation?.optimistic_p90 || {};
|
||||
const baseline = simulation?.baseline || {};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-200 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="w-5 h-5 text-brand-500" />
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
Simulador «¿Qué pasa si…?»
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1 max-w-xl">
|
||||
Proyección con CPL, tasa de cierre y ticket medio de{' '}
|
||||
<strong className="text-gray-600">
|
||||
{simulation?.tenant_label || tenantLabel || 'este cliente'}
|
||||
</strong>
|
||||
. Sin TikTok (aún sin campañas activas).
|
||||
</p>
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 border-b border-gray-200 pb-4">
|
||||
<div className="min-w-0 flex-1 [&>div]:mb-0">
|
||||
<SectionHead
|
||||
icon={<Calculator className="w-4 h-4" />}
|
||||
title="Simulador «¿Qué pasa si…?»"
|
||||
hint={`Si inviertes este presupuesto solo en el canal elegido, con el CPL histórico de ${simulation?.tenant_label || tenantLabel || 'este cliente'}. No es un split Meta+Google.`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{CHANNELS.map((ch) => (
|
||||
@@ -113,9 +108,9 @@ export default function BudgetSimulator({
|
||||
key={ch.id}
|
||||
type="button"
|
||||
onClick={() => setChannel(ch.id)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||
channel === ch.id
|
||||
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
@@ -130,7 +125,7 @@ export default function BudgetSimulator({
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Inversión adicional proyectada
|
||||
</label>
|
||||
<span className="text-lg font-extrabold text-brand-400">
|
||||
<span className="text-lg font-extrabold text-brand-600">
|
||||
{spend.toLocaleString('es-ES')} €
|
||||
{loading ? (
|
||||
<span className="text-[10px] text-gray-500 ml-2 font-normal">
|
||||
@@ -157,130 +152,177 @@ export default function BudgetSimulator({
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<p className="text-xs text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-red-500 border border-red-200 rounded-lg px-3 py-2">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 bg-gray-50 p-3 rounded-lg border border-gray-200 text-xs text-gray-600">
|
||||
<span className="text-gray-400">Baseline del cliente:</span>
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
|
||||
<span className="text-gray-400">Baseline {CHANNELS.find((c) => c.id === channel)?.label}:</span>
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||||
CPL:{' '}
|
||||
<strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
||||
</span>
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
|
||||
Cierre:{' '}
|
||||
<strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||||
Cierre: <strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||
</span>
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||||
Ticket:{' '}
|
||||
<strong>
|
||||
{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €
|
||||
</strong>
|
||||
</span>
|
||||
{baseline.sample_spend != null ? (
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-400">
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-500">
|
||||
Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '}
|
||||
{baseline.sample_leads || 0} leads
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400">
|
||||
Conservador (p10)
|
||||
</span>
|
||||
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
+
|
||||
{Number(p10.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||
ROAS {p10.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-400 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-800">{p10.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-800">{p10.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<tr>
|
||||
<th className="text-left font-semibold px-3 py-2">Canal</th>
|
||||
<th className="text-right font-semibold px-3 py-2">CPL hist.</th>
|
||||
<th className="text-right font-semibold px-3 py-2">Leads p50</th>
|
||||
<th className="text-right font-semibold px-3 py-2">Ingresos p50</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CHANNELS.map((ch) => {
|
||||
const row = byChannel[ch.id];
|
||||
const active = channel === ch.id;
|
||||
return (
|
||||
<tr
|
||||
key={ch.id}
|
||||
className={`border-t border-gray-100 ${
|
||||
active ? 'bg-brand-50/70' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-semibold text-gray-900">
|
||||
{ch.label}
|
||||
{active ? (
|
||||
<span className="ml-2 text-[10px] uppercase text-brand-700">
|
||||
activo
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{Number(row?.baseline?.historical_cpl || 0).toFixed(2)} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{row?.expected_p50?.leads || 0}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold text-gray-900">
|
||||
+{euro(Number(row?.expected_p50?.revenue || 0))} €
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
|
||||
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
||||
Más probable
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-brand-700">
|
||||
Esperado (p50)
|
||||
</span>
|
||||
<Sparkles className="w-4 h-4 text-brand-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
+
|
||||
{Number(p50.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-brand-400 font-semibold mt-1">
|
||||
ROAS {p50.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-900">{p50.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-900">{p50.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-1">
|
||||
<BandCard
|
||||
title="Conservador (p10)"
|
||||
icon={<ShieldCheck className="w-4 h-4 text-amber-500" />}
|
||||
band={p10}
|
||||
highlight={false}
|
||||
/>
|
||||
<BandCard
|
||||
title="Esperado (p50)"
|
||||
icon={<Sparkles className="w-4 h-4 text-brand-500" />}
|
||||
band={p50}
|
||||
highlight
|
||||
/>
|
||||
<BandCard
|
||||
title="Optimista (p90)"
|
||||
icon={<TrendingUp className="w-4 h-4 text-emerald-500" />}
|
||||
band={p90}
|
||||
highlight={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400">
|
||||
Optimista (p90)
|
||||
</span>
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
+
|
||||
{Number(p90.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||
ROAS {p90.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-400 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-800">{p90.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-800">{p90.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<HelpDetails title="Por qué Meta + Google no suma el Blended">
|
||||
<p>
|
||||
Cada pestaña simula invertir <strong>todo</strong> este presupuesto en
|
||||
un solo canal. No es «cómo se reparte el blended». Por eso 1.000 € en
|
||||
Meta + 1.000 € en Google serían 2.000 €, no el blended de 1.000 €.
|
||||
</p>
|
||||
<p>
|
||||
El blended usa el <strong>CPL ponderado</strong> (gasto total ÷ leads
|
||||
totales), no la media de las dos proyecciones. Si Google es más caro
|
||||
por lead, blended queda entre Meta y Google, más cerca del canal con
|
||||
más volumen histórico.
|
||||
</p>
|
||||
<p>
|
||||
Cierre y ticket salen de <code>kpi_weekly</code> del canal. Si un
|
||||
canal no tiene semanas propias, hereda el cierre del tenant (mismo
|
||||
ticket, CPL distinto).
|
||||
</p>
|
||||
</HelpDetails>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BandCard({
|
||||
title,
|
||||
icon,
|
||||
band,
|
||||
highlight,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
band: SimBand;
|
||||
highlight: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl p-4 flex flex-col justify-between border ${
|
||||
highlight
|
||||
? 'border-2 border-brand-500 bg-gradient-to-b from-brand-50 to-white relative shadow-sm'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{highlight ? (
|
||||
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
||||
Más probable
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className={`text-xs font-semibold ${
|
||||
highlight ? 'text-brand-700' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900 tabular-nums">
|
||||
+
|
||||
{Number(band.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-brand-700 font-semibold mt-1">
|
||||
ROAS {band.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-500 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-800">{band.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-800">{band.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
282
src/components/CalendarDayZoom.tsx
Normal file
282
src/components/CalendarDayZoom.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Heart,
|
||||
Instagram,
|
||||
MessageCircle,
|
||||
Repeat2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
instagramUrlFromNotes,
|
||||
pipelineMediaUrl,
|
||||
slidesOf,
|
||||
type PipelineCalendarRow,
|
||||
normPermalink,
|
||||
} from '@/lib/pipeline-calendar';
|
||||
|
||||
export type SocialLite = {
|
||||
permalink?: string | null;
|
||||
createdAt?: string | null;
|
||||
likes?: number;
|
||||
comments?: number;
|
||||
shares?: number;
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
mediaUrl?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
};
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 inline-flex items-center gap-1">
|
||||
{icon}
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-lg font-semibold tabular-nums text-gray-900 mt-0.5">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarDayZoom({
|
||||
day,
|
||||
items,
|
||||
socialPosts,
|
||||
onClose,
|
||||
}: {
|
||||
day: string;
|
||||
items: PipelineCalendarRow[];
|
||||
socialPosts?: SocialLite[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [slide, setSlide] = useState(0);
|
||||
const item = items[Math.min(idx, items.length - 1)] || null;
|
||||
|
||||
useEffect(() => {
|
||||
setIdx(0);
|
||||
setSlide(0);
|
||||
}, [day, items.length]);
|
||||
|
||||
const images = useMemo(() => {
|
||||
if (!item) return [];
|
||||
const all = [item.cover_path, ...slidesOf(item.slide_paths)].filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
return all;
|
||||
}, [item]);
|
||||
|
||||
const permalink = item ? instagramUrlFromNotes(item.notes) : null;
|
||||
const metrics = useMemo(() => {
|
||||
if (!item) return null;
|
||||
const key = normPermalink(permalink);
|
||||
const date = (item.created_at || item.publish_date || '').slice(0, 10);
|
||||
return (
|
||||
(socialPosts || []).find((p) => {
|
||||
if (key && normPermalink(p.permalink) === key) return true;
|
||||
if (date && (p.createdAt || '').slice(0, 10) === date) return true;
|
||||
return false;
|
||||
}) || null
|
||||
);
|
||||
}, [item, permalink, socialPosts]);
|
||||
|
||||
const currentPath = images[slide] || item?.cover_path || null;
|
||||
const currentUrl =
|
||||
pipelineMediaUrl(currentPath) ||
|
||||
metrics?.mediaUrl ||
|
||||
metrics?.thumbnailUrl ||
|
||||
null;
|
||||
|
||||
const dayLabel = new Date(`${day}T12:00:00`).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[92vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl md:flex-row"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-1 flex-col bg-gray-950 p-4 min-h-[280px]">
|
||||
<div className="relative flex flex-1 items-center justify-center overflow-hidden rounded-lg bg-black min-h-[260px]">
|
||||
{currentUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={item?.title || ''}
|
||||
className="max-h-[62vh] max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sin preview de esta pieza</p>
|
||||
)}
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white"
|
||||
onClick={() =>
|
||||
setSlide((s) => (s > 0 ? s - 1 : images.length - 1))
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white"
|
||||
onClick={() =>
|
||||
setSlide((s) => (s < images.length - 1 ? s + 1 : 0))
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-2 py-0.5 text-[11px] text-white">
|
||||
{slide + 1} / {images.length}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{items.length > 1 ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto">
|
||||
{items.map((it, i) => {
|
||||
const thumb = pipelineMediaUrl(it.cover_path);
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIdx(i);
|
||||
setSlide(0);
|
||||
}}
|
||||
className={`relative h-16 w-12 shrink-0 overflow-hidden rounded-md border-2 ${
|
||||
i === idx
|
||||
? 'border-brand-500'
|
||||
: 'border-transparent opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{thumb ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center bg-gray-800 text-[9px] text-white">
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col md:w-[22rem] border-t md:border-t-0 md:border-l border-gray-100 overflow-y-auto">
|
||||
<div className="flex items-start justify-between gap-2 p-5 pb-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
|
||||
{dayLabel}
|
||||
</p>
|
||||
<h3 className="mt-1 text-base font-bold text-gray-900 leading-snug">
|
||||
{item?.title || 'Publicación'}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{(item?.format || 'post').toString()} ·{' '}
|
||||
{items.length > 1
|
||||
? `${idx + 1} de ${items.length} este día`
|
||||
: 'Creatividad real (pipeline)'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-gray-400 hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 grid grid-cols-2 gap-2">
|
||||
<Metric
|
||||
icon={<Heart className="h-3 w-3" />}
|
||||
label="Likes"
|
||||
value={String(metrics?.likes ?? '—')}
|
||||
/>
|
||||
<Metric
|
||||
icon={<MessageCircle className="h-3 w-3" />}
|
||||
label="Comentarios"
|
||||
value={String(metrics?.comments ?? '—')}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Alcance"
|
||||
value={
|
||||
metrics?.reach != null
|
||||
? Number(metrics.reach).toLocaleString('es-ES')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Repeat2 className="h-3 w-3" />}
|
||||
label="Impresiones"
|
||||
value={
|
||||
metrics?.impressions != null
|
||||
? Number(metrics.impressions).toLocaleString('es-ES')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-3 flex-1">
|
||||
{permalink ? (
|
||||
<a
|
||||
href={permalink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-full items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white"
|
||||
>
|
||||
<Instagram className="h-4 w-4" />
|
||||
Ver en Instagram
|
||||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||
</a>
|
||||
) : null}
|
||||
{item?.caption ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||
{item.caption}
|
||||
</div>
|
||||
) : null}
|
||||
{!metrics ? (
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Métricas en vivo cuando hay match con el post de Instagram
|
||||
(likes, comentarios, alcance).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface } from '@/components/ui';
|
||||
import { clientNoteFromNotes } from '@/lib/pipeline-calendar';
|
||||
import Link from 'next/link';
|
||||
|
||||
type PipelineItem = {
|
||||
id: string;
|
||||
@@ -108,6 +110,8 @@ export default function CreativePipelineBoard() {
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>('all');
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('all');
|
||||
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
|
||||
const [rejectItem, setRejectItem] = useState<PipelineItem | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -129,6 +133,7 @@ export default function CreativePipelineBoard() {
|
||||
const monthCounts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
if (it.status === 'publicado') continue;
|
||||
const key = it.month || 'sin-mes';
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
}
|
||||
@@ -138,6 +143,7 @@ export default function CreativePipelineBoard() {
|
||||
const months = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const it of items) {
|
||||
if (it.status === 'publicado') continue;
|
||||
if (it.month) set.add(it.month);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
@@ -145,6 +151,7 @@ export default function CreativePipelineBoard() {
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((it) => {
|
||||
if (it.status === 'publicado') return false;
|
||||
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false;
|
||||
if (selectedFormat !== 'all' && it.format !== selectedFormat) return false;
|
||||
return true;
|
||||
@@ -181,14 +188,14 @@ export default function CreativePipelineBoard() {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function setStatus(id: string, status: string) {
|
||||
async function setStatus(id: string, status: string, notes?: string) {
|
||||
setBusyId(id);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/creative-pipeline', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
body: JSON.stringify({ id, status, ...(notes ? { notes } : {}) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
@@ -196,7 +203,11 @@ export default function CreativePipelineBoard() {
|
||||
return;
|
||||
}
|
||||
if (previewItem && previewItem.id === id) {
|
||||
setPreviewItem({ ...previewItem, status });
|
||||
setPreviewItem({
|
||||
...previewItem,
|
||||
status,
|
||||
notes: notes || previewItem.notes,
|
||||
});
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
@@ -246,7 +257,7 @@ export default function CreativePipelineBoard() {
|
||||
<PageHeader
|
||||
eyebrow="Producción editorial"
|
||||
title="Panel de Creatividades"
|
||||
description="Planificación, diseño y archivo histórico sincronizado con Instagram."
|
||||
description="Solo revisión: propuesta, diseño y OK del cliente. Lo publicado se ve en el calendario, día a día."
|
||||
actions={
|
||||
isAgency ? (
|
||||
<button
|
||||
@@ -269,13 +280,20 @@ export default function CreativePipelineBoard() {
|
||||
{err}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mb-4 text-xs text-gray-500">
|
||||
Piezas ya publicadas:{' '}
|
||||
<Link href="/dashboard/calendario" className="font-semibold text-brand-700 hover:underline">
|
||||
ábrelas en el calendario, clic en el día
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Barra de Filtros por Mes y Formato */}
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Meses ({items.length} piezas):
|
||||
Meses ({items.filter((i) => i.status !== 'publicado').length} en revisión):
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
@@ -416,7 +434,14 @@ export default function CreativePipelineBoard() {
|
||||
item={it}
|
||||
isAgency={isAgency}
|
||||
busy={busyId === it.id}
|
||||
onStatus={(s) => setStatus(it.id, s)}
|
||||
onStatus={(s) => {
|
||||
if (s === 'rechazado' && !isAgency) {
|
||||
setRejectItem(it);
|
||||
setRejectNote('');
|
||||
return;
|
||||
}
|
||||
void setStatus(it.id, s);
|
||||
}}
|
||||
onDelete={() => removeItem(it.id)}
|
||||
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
|
||||
onPreview={() => setPreviewItem(it)}
|
||||
@@ -435,9 +460,68 @@ export default function CreativePipelineBoard() {
|
||||
isAgency={isAgency}
|
||||
busy={busyId === previewItem.id}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
onStatus={(s) => setStatus(previewItem.id, s)}
|
||||
onStatus={(s) => {
|
||||
if (s === 'rechazado' && !isAgency) {
|
||||
setRejectItem(previewItem);
|
||||
setRejectNote('');
|
||||
return;
|
||||
}
|
||||
void setStatus(previewItem.id, s);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rejectItem ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={() => setRejectItem(null)}
|
||||
>
|
||||
<form
|
||||
className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!rejectNote.trim()) return;
|
||||
const id = rejectItem.id;
|
||||
void setStatus(id, 'rechazado', rejectNote.trim()).then(() => {
|
||||
setRejectItem(null);
|
||||
setRejectNote('');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
No aprobar · {rejectItem.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Deja una nota para que 4Ventures sepa qué hay que cambiar.
|
||||
</p>
|
||||
<textarea
|
||||
required
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Ej. Cambia el titular, el logo se ve pequeño…"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRejectItem(null)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!rejectNote.trim() || busyId === rejectItem.id}
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
Enviar nota y rechazar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -617,6 +701,12 @@ function PieceCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.status === 'rechazado' && clientNoteFromNotes(item.notes) ? (
|
||||
<p className="text-[11px] text-red-800 bg-red-50 border border-red-100 rounded-lg px-2 py-1.5">
|
||||
Nota del cliente: {clientNoteFromNotes(item.notes)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{isAgency ? (
|
||||
<select
|
||||
@@ -820,6 +910,12 @@ function DetailModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.status === 'rechazado' && clientNoteFromNotes(item.notes) ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3 text-xs text-red-900">
|
||||
<p className="font-semibold mb-1">Nota del cliente</p>
|
||||
{clientNoteFromNotes(item.notes)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||
|
||||
615
src/components/ObjetivosDashboard.tsx
Normal file
615
src/components/ObjetivosDashboard.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Target,
|
||||
Trophy,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
HelpDetails,
|
||||
HeroBand,
|
||||
HeroStat,
|
||||
SectionHead,
|
||||
SparkBars,
|
||||
} from '@/components/dash-ui';
|
||||
|
||||
type Tone = 'ahead' | 'on_track' | 'behind';
|
||||
|
||||
type Payload = {
|
||||
tenant_label?: string;
|
||||
month: string;
|
||||
as_of: string;
|
||||
days_in_month: number;
|
||||
days_elapsed: number;
|
||||
days_remaining: number;
|
||||
pace_pct: number;
|
||||
has_targets: boolean;
|
||||
targets: { spend: number; leads: number; clients: number; revenue: number };
|
||||
actual: {
|
||||
spend: number;
|
||||
leads: number;
|
||||
clients: number;
|
||||
revenue: number;
|
||||
deals: number;
|
||||
};
|
||||
expected: {
|
||||
spend: number;
|
||||
leads: number;
|
||||
clients: number;
|
||||
revenue: number;
|
||||
};
|
||||
status: {
|
||||
spend: Tone;
|
||||
leads: Tone;
|
||||
clients: Tone;
|
||||
revenue: Tone;
|
||||
};
|
||||
platforms: { meta: number; google: number; other: number };
|
||||
series: { date: string; spend: number; crmLeads: number }[];
|
||||
cumulative: {
|
||||
date: string;
|
||||
spend: number;
|
||||
spendPace: number;
|
||||
leads: number;
|
||||
leadsPace: number;
|
||||
}[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const TONE: Record<Tone, { label: string; className: string }> = {
|
||||
ahead: {
|
||||
label: 'Por delante',
|
||||
className: 'bg-emerald-50 text-emerald-800 border-emerald-200',
|
||||
},
|
||||
on_track: {
|
||||
label: 'En ritmo',
|
||||
className: 'bg-sky-50 text-sky-800 border-sky-200',
|
||||
},
|
||||
behind: {
|
||||
label: 'Por detrás',
|
||||
className: 'bg-amber-50 text-amber-900 border-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
function euro(v: number, digits = 0) {
|
||||
return `${Number(v || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})} €`;
|
||||
}
|
||||
|
||||
function shiftMonth(ym: string, delta: number): string {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function monthTitle(ym: string) {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const label = new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString('es-ES', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function DualLine({
|
||||
rows,
|
||||
aKey,
|
||||
bKey,
|
||||
aLabel,
|
||||
bLabel,
|
||||
format,
|
||||
}: {
|
||||
rows: { date: string; [k: string]: string | number }[];
|
||||
aKey: string;
|
||||
bKey: string;
|
||||
aLabel: string;
|
||||
bLabel: string;
|
||||
format: (v: number) => string;
|
||||
}) {
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<p className="text-xs text-gray-500 py-10 text-center">
|
||||
Aún no hay datos de este mes.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const w = 640;
|
||||
const h = 180;
|
||||
const pad = { t: 12, r: 12, b: 22, l: 8 };
|
||||
const vals = rows.flatMap((r) => [Number(r[aKey] || 0), Number(r[bKey] || 0)]);
|
||||
const max = Math.max(1, ...vals);
|
||||
const innerW = w - pad.l - pad.r;
|
||||
const innerH = h - pad.t - pad.b;
|
||||
const x = (i: number) =>
|
||||
pad.l + (rows.length === 1 ? innerW / 2 : (i / (rows.length - 1)) * innerW);
|
||||
const y = (v: number) => pad.t + innerH - (v / max) * innerH;
|
||||
const path = (key: string) =>
|
||||
rows
|
||||
.map((r, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(Number(r[key] || 0)).toFixed(1)}`)
|
||||
.join(' ');
|
||||
const area = `${path(aKey)} L ${x(rows.length - 1).toFixed(1)} ${y(0).toFixed(1)} L ${x(0).toFixed(1)} ${y(0).toFixed(1)} Z`;
|
||||
const first = rows[0]?.date;
|
||||
const last = rows[rows.length - 1]?.date;
|
||||
const fmtDay = (d?: string) =>
|
||||
d
|
||||
? new Date(`${d}T00:00:00`).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-44" role="img">
|
||||
<path d={area} fill="rgb(249 115 22 / 0.12)" />
|
||||
<path
|
||||
d={path(aKey)}
|
||||
fill="none"
|
||||
stroke="#ea580c"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={path(bKey)}
|
||||
fill="none"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-500 tabular-nums">
|
||||
<span>{fmtDay(first)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-1.5 w-4 rounded-full bg-orange-600" />
|
||||
{aLabel} {format(Number(rows[rows.length - 1]?.[aKey] || 0))}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-px w-4 border-t-2 border-dashed border-slate-400" />
|
||||
{bLabel} {format(Number(rows[rows.length - 1]?.[bKey] || 0))}
|
||||
</span>
|
||||
</div>
|
||||
<span>{fmtDay(last)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ tone }: { tone: Tone }) {
|
||||
const t = TONE[tone];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${t.className}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
suffix,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
suffix?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
<div className="mt-1.5 flex items-center rounded-xl border border-gray-200 bg-white focus-within:border-brand-400 focus-within:ring-2 focus-within:ring-brand-100">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-xl bg-transparent px-3 py-2.5 text-sm font-semibold tabular-nums text-gray-900 outline-none"
|
||||
/>
|
||||
{suffix ? (
|
||||
<span className="pr-3 text-xs text-gray-400">{suffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ObjetivosDashboard({
|
||||
tenantLabel,
|
||||
}: {
|
||||
tenantLabel?: string;
|
||||
}) {
|
||||
const nowYm = useMemo(() => {
|
||||
const s = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Madrid',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
}).format(new Date());
|
||||
return s;
|
||||
}, []);
|
||||
const [month, setMonth] = useState(nowYm);
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
spend_target: '',
|
||||
leads_target: '',
|
||||
clients_target: '',
|
||||
revenue_target: '',
|
||||
});
|
||||
|
||||
const load = useCallback(async (ym: string) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch(`/api/targets?month=${encodeURIComponent(ym)}`);
|
||||
const json = (await res.json()) as Payload;
|
||||
if (!res.ok) {
|
||||
setErr(json.error || 'No se pudieron cargar los objetivos');
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setData(json);
|
||||
setForm({
|
||||
spend_target: String(json.targets.spend || ''),
|
||||
leads_target: String(json.targets.leads || ''),
|
||||
clients_target: String(json.targets.clients || ''),
|
||||
revenue_target: String(json.targets.revenue || ''),
|
||||
});
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(month);
|
||||
}, [month, load]);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/targets', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
month,
|
||||
spend_target: Number(form.spend_target || 0),
|
||||
leads_target: Number(form.leads_target || 0),
|
||||
clients_target: Number(form.clients_target || 0),
|
||||
revenue_target: Number(form.revenue_target || 0),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(json.error || 'No se pudo guardar');
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
await load(month);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const t = data?.targets;
|
||||
const a = data?.actual;
|
||||
const e = data?.expected;
|
||||
const st = data?.status;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="inline-flex items-center rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth((m) => shiftMonth(m, -1))}
|
||||
className="p-2 text-gray-500 hover:text-gray-900"
|
||||
aria-label="Mes anterior"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 min-w-[10rem] justify-center">
|
||||
<CalendarDays className="h-4 w-4 text-brand-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{monthTitle(month)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||
className="p-2 text-gray-500 hover:text-gray-900"
|
||||
aria-label="Mes siguiente"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing((v) => !v)}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:border-brand-300 hover:text-brand-800"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
{editing ? 'Cerrar editor' : 'Editar objetivos'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<p className="text-sm text-red-600 border border-red-200 bg-red-50 rounded-xl px-3 py-2">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editing ? (
|
||||
<form
|
||||
onSubmit={save}
|
||||
className="rounded-2xl border border-brand-200 bg-brand-50/40 p-5 space-y-4"
|
||||
>
|
||||
<SectionHead
|
||||
icon={<Pencil className="h-4 w-4" />}
|
||||
title="Fijar metas del mes"
|
||||
hint={`Se comparan con el real de ${tenantLabel || data?.tenant_label || 'este cliente'} a día de hoy. El ritmo esperado es lineal (${data?.days_elapsed || 0}/${data?.days_in_month || 30} días).`}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field
|
||||
label="Presupuesto ads"
|
||||
suffix="€"
|
||||
value={form.spend_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, spend_target: v }))}
|
||||
/>
|
||||
<Field
|
||||
label="Leads"
|
||||
value={form.leads_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, leads_target: v }))}
|
||||
/>
|
||||
<Field
|
||||
label="Clientes"
|
||||
value={form.clients_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, clients_target: v }))}
|
||||
/>
|
||||
<Field
|
||||
label="Facturación"
|
||||
suffix="€"
|
||||
value={form.revenue_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, revenue_target: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{saving ? 'Guardando…' : 'Guardar y comparar'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">
|
||||
0 = sin meta en esa métrica (no entra en el ritmo).
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<HeroBand
|
||||
eyebrow={
|
||||
loading
|
||||
? 'Cargando mes…'
|
||||
: `Día ${data?.days_elapsed || 0} de ${data?.days_in_month || 0} · ritmo lineal ${data?.pace_pct || 0}%`
|
||||
}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HeroStat
|
||||
label="Presupuesto ads"
|
||||
value={euro(a?.spend || 0)}
|
||||
chip={
|
||||
t?.spend ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {euro(t.spend)} · ritmo {euro(e?.spend || 0)}{' '}
|
||||
{st?.spend ? <Chip tone={st.spend} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin meta de gasto</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Leads CRM"
|
||||
value={String(a?.leads || 0)}
|
||||
chip={
|
||||
t?.leads ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {t.leads} · ritmo {Math.round(e?.leads || 0)}{' '}
|
||||
{st?.leads ? <Chip tone={st.leads} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin meta de leads</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Clientes"
|
||||
value={String(a?.clients || 0)}
|
||||
chip={
|
||||
t?.clients ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {t.clients} · ritmo {Math.round(e?.clients || 0)}{' '}
|
||||
{st?.clients ? <Chip tone={st.clients} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">
|
||||
Sin meta de clientes
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Facturación"
|
||||
value={euro(a?.revenue || 0)}
|
||||
chip={
|
||||
t?.revenue ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {euro(t.revenue)} · ritmo {euro(e?.revenue || 0)}{' '}
|
||||
{st?.revenue ? <Chip tone={st.revenue} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">
|
||||
Sin meta de facturación
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</HeroBand>
|
||||
|
||||
{!data?.has_targets && !loading ? (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-5 py-8 text-center">
|
||||
<Target className="mx-auto h-6 w-6 text-brand-600" />
|
||||
<p className="mt-2 text-sm font-semibold text-gray-900">
|
||||
Aún no hay objetivos este mes
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 max-w-md mx-auto">
|
||||
Define presupuesto, leads, clientes y facturación. En cuanto
|
||||
guardes, verás el real contra el ritmo esperado y las curvas
|
||||
acumuladas.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Definir objetivos
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<SectionHead
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
title="Gasto acumulado vs ritmo"
|
||||
hint="Línea continua = gastado. Discontinua = si el presupuesto se consume linealmente."
|
||||
/>
|
||||
<DualLine
|
||||
rows={data?.cumulative || []}
|
||||
aKey="spend"
|
||||
bKey="spendPace"
|
||||
aLabel="Real"
|
||||
bLabel="Ritmo"
|
||||
format={(v) => euro(v)}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<SectionHead
|
||||
icon={<Trophy className="h-4 w-4" />}
|
||||
title="Leads CRM acumulados vs ritmo"
|
||||
hint="Altas del CRM en el mes. Si no hay meta de leads, el ritmo queda en 0."
|
||||
/>
|
||||
<DualLine
|
||||
rows={data?.cumulative || []}
|
||||
aKey="leads"
|
||||
bKey="leadsPace"
|
||||
aLabel="Real"
|
||||
bLabel="Ritmo"
|
||||
format={(v) => String(Math.round(v))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<SectionHead
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
title="Gasto diario"
|
||||
hint="Barras de ad_daily. Útil para ver picos, no el ritmo."
|
||||
/>
|
||||
<SparkBars
|
||||
points={(data?.series || []).map((d) => ({
|
||||
date: d.date,
|
||||
value: d.spend,
|
||||
}))}
|
||||
color="bg-orange-500"
|
||||
formatValue={(v) => euro(v)}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-4">
|
||||
<SectionHead
|
||||
icon={<Target className="h-4 w-4" />}
|
||||
title="Mix de inversión"
|
||||
hint="Cómo se reparte el gasto real del mes (no es el mix del simulador)."
|
||||
/>
|
||||
{['meta', 'google', 'other'].map((k) => {
|
||||
const val = Number(
|
||||
data?.platforms?.[k as 'meta' | 'google' | 'other'] || 0
|
||||
);
|
||||
const max = Math.max(
|
||||
1,
|
||||
data?.actual.spend || 1
|
||||
);
|
||||
const label =
|
||||
k === 'meta' ? 'Meta' : k === 'google' ? 'Google' : 'Otros';
|
||||
return (
|
||||
<div key={k}>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{label}</span>
|
||||
<span className="tabular-nums font-semibold text-gray-900">
|
||||
{euro(val)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-100">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
k === 'meta'
|
||||
? 'bg-sky-500'
|
||||
: k === 'google'
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
style={{ width: `${Math.max(2, (val / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<HelpDetails title="Cómo se calcula el avance">
|
||||
<p>
|
||||
<strong>Presupuesto</strong> = suma de <code>ad_daily.spend</code> del
|
||||
mes. <strong>Leads</strong> = altas CRM (Clientify / GoHighLevel).{' '}
|
||||
<strong>Clientes</strong> = leads que pasaron a cliente en el mes.{' '}
|
||||
<strong>Facturación</strong> = deals ganados (<code>won_at</code>) con
|
||||
importe.
|
||||
</p>
|
||||
<p>
|
||||
El ritmo esperado es lineal: meta × (día del mes / días del mes).
|
||||
«Por delante / en ritmo / por detrás» usa un umbral del 8% sobre ese
|
||||
ritmo, no sobre el 100% del mes (aún no ha terminado).
|
||||
</p>
|
||||
</HelpDetails>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
X,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import CalendarDayZoom, {
|
||||
type SocialLite,
|
||||
} from '@/components/CalendarDayZoom';
|
||||
import {
|
||||
pipelineMediaUrl,
|
||||
pipelinePublishDay,
|
||||
type PipelineCalendarRow,
|
||||
} from '@/lib/pipeline-calendar';
|
||||
|
||||
export type RrssPiece = {
|
||||
id: string;
|
||||
@@ -31,6 +39,9 @@ export type RrssPiece = {
|
||||
kind?: string;
|
||||
color?: string;
|
||||
slides?: string[];
|
||||
pipeline_id?: string;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[];
|
||||
};
|
||||
|
||||
export type RrssMonthPlan = {
|
||||
@@ -120,6 +131,21 @@ function DualBadge({ dual }: { dual?: Dual }) {
|
||||
);
|
||||
}
|
||||
|
||||
function pieceToRow(p: RrssPiece): PipelineCalendarRow {
|
||||
return {
|
||||
id: p.pipeline_id || p.id,
|
||||
title: p.title,
|
||||
format: p.format,
|
||||
status: p.status,
|
||||
cover_path: p.cover_path || null,
|
||||
slide_paths: p.slide_paths || p.slides || [],
|
||||
caption: p.body || null,
|
||||
notes: p.meta_line || null,
|
||||
created_at: p.date,
|
||||
publish_date: p.date,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RrssMonthView({
|
||||
plan,
|
||||
tenantLabel,
|
||||
@@ -134,6 +160,9 @@ export default function RrssMonthView({
|
||||
const pieces = plan.pieces || [];
|
||||
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
|
||||
const [selected, setSelected] = useState<RrssPiece | null>(null);
|
||||
const [dayZoom, setDayZoom] = useState<string | null>(null);
|
||||
const [pipeItems, setPipeItems] = useState<PipelineCalendarRow[]>([]);
|
||||
const [socialPosts, setSocialPosts] = useState<SocialLite[]>([]);
|
||||
const [dualMap, setDualMap] = useState<Record<string, Dual>>({});
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
@@ -172,6 +201,32 @@ export default function RrssMonthView({
|
||||
refreshDual();
|
||||
}, [refreshDual]);
|
||||
|
||||
useEffect(() => {
|
||||
const ym = plan.month;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/creative-pipeline?month=${encodeURIComponent(ym)}`
|
||||
);
|
||||
const j = await r.json();
|
||||
setPipeItems((j.items || []) as PipelineCalendarRow[]);
|
||||
} catch {
|
||||
setPipeItems([]);
|
||||
}
|
||||
})();
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/social?since=${encodeURIComponent(ym + '-01')}&until=${encodeURIComponent(ym + '-31')}`
|
||||
);
|
||||
const j = await r.json();
|
||||
setSocialPosts((j.posts || []) as SocialLite[]);
|
||||
} catch {
|
||||
setSocialPosts([]);
|
||||
}
|
||||
})();
|
||||
}, [plan.month]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected || !canCollaborate) {
|
||||
setComments([]);
|
||||
@@ -253,6 +308,31 @@ export default function RrssMonthView({
|
||||
: dualMap[selected.id].client
|
||||
: false;
|
||||
|
||||
function rowsForDay(key: string): PipelineCalendarRow[] {
|
||||
const fromPipe = pipeItems.filter((it) => pipelinePublishDay(it) === key);
|
||||
const fromPlan = (byDate.get(key) || [])
|
||||
.map((p) => pieceToRow(p))
|
||||
.filter((r) => r.cover_path || r.caption || r.notes);
|
||||
const seen = new Set<string>();
|
||||
const out: PipelineCalendarRow[] = [];
|
||||
for (const r of [...fromPipe, ...fromPlan]) {
|
||||
const k = r.id;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function openDay(key: string, piece?: RrssPiece) {
|
||||
const rows = rowsForDay(key);
|
||||
if (rows.length) {
|
||||
setDayZoom(key);
|
||||
return;
|
||||
}
|
||||
if (piece) setSelected(piece);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hub crumbs + network filters */}
|
||||
@@ -457,8 +537,8 @@ export default function RrssMonthView({
|
||||
El mes de un vistazo
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Cada tarjeta de color es una publicación; clic para ver copy, aprobar y
|
||||
comentar.
|
||||
Clic en el día publicado para ver la creatividad real (preview +
|
||||
métricas). El pipeline es solo para revisión.
|
||||
</p>
|
||||
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
|
||||
@@ -481,33 +561,51 @@ export default function RrssMonthView({
|
||||
const day = i + 1;
|
||||
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
|
||||
const dayPieces = byDate.get(key) || [];
|
||||
const pipeDay = pipeItems.filter(
|
||||
(it) => pipelinePublishDay(it) === key
|
||||
);
|
||||
const cover = pipeDay[0]
|
||||
? pipelineMediaUrl(pipeDay[0].cover_path)
|
||||
: dayPieces.find((p) => p.cover_path)
|
||||
? pipelineMediaUrl(
|
||||
dayPieces.find((p) => p.cover_path)!.cover_path!
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={key}
|
||||
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5"
|
||||
type="button"
|
||||
onClick={() => openDay(key, dayPieces[0])}
|
||||
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5 text-left hover:border-brand-400 hover:shadow-sm overflow-hidden"
|
||||
>
|
||||
<p className="text-[11px] font-bold text-gray-600 mb-1">{day}</p>
|
||||
{cover ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="mb-1 h-10 w-full rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{dayPieces.slice(0, 3).map((p) => (
|
||||
<button
|
||||
{dayPieces.slice(0, cover ? 1 : 3).map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(p)}
|
||||
className="w-full text-left rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
|
||||
className="block w-full rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
|
||||
style={{ background: colorFor(p) }}
|
||||
title={p.title}
|
||||
>
|
||||
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
|
||||
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{dayPieces.length > 3 && (
|
||||
{dayPieces.length > (cover ? 1 : 3) && (
|
||||
<p className="text-[9px] text-gray-500">
|
||||
+{dayPieces.length - 3}
|
||||
+{dayPieces.length - (cover ? 1 : 3)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -522,7 +620,7 @@ export default function RrssMonthView({
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(p)}
|
||||
onClick={() => openDay(p.date.slice(0, 10), p)}
|
||||
className="w-full text-left rounded-xl border border-gray-200 bg-white p-4 hover:border-gray-200"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -573,6 +671,15 @@ export default function RrssMonthView({
|
||||
))}
|
||||
</section>
|
||||
|
||||
{dayZoom ? (
|
||||
<CalendarDayZoom
|
||||
day={dayZoom}
|
||||
items={rowsForDay(dayZoom)}
|
||||
socialPosts={socialPosts}
|
||||
onClose={() => setDayZoom(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selected && (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
|
||||
Reference in New Issue
Block a user