merca y ch

This commit is contained in:
Marcelo
2026-03-31 13:21:48 +00:00
commit 773bfab393
326 changed files with 52705 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { default } from "../page";

View File

@@ -0,0 +1 @@
export { default } from "../page";

View File

@@ -0,0 +1,935 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import {
departmentScore,
initiativeScore,
marketingMockData,
type BrandPulse,
type Employee,
type Initiative,
type Meeting,
type MetaSyncStatus,
type MarketingMetaConnection,
type MarketingRole,
type Milestone,
type SocialRange,
type SocialInsight,
type SocialSnapshot,
type Task,
} from "@/lib/marketing";
import { MARKETING_DATE_RANGE_LABELS } from "@/lib/marketing/labels";
import BrandPulseCard from "@/components/marketing/BrandPulseCard";
import EmployeePerformanceTable from "@/components/marketing/EmployeePerformanceTable";
import HealthScoreCard from "@/components/marketing/HealthScoreCard";
import InitiativeDrawer from "@/components/marketing/InitiativeDrawer";
import InitiativeTable from "@/components/marketing/InitiativeTable";
import DepartmentKpiTracker from "@/components/kpis/DepartmentKpiTracker";
import MarketingHeader from "@/components/marketing/MarketingHeader";
import MeetingsWidget from "@/components/marketing/MeetingsWidget";
import SocialPanel from "@/components/marketing/SocialPanel";
import { useMarketingUIStore } from "@/stores/uiStore";
const NOW = new Date("2026-02-17T12:00:00-06:00").getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
function matchesDateRange(initiative: Initiative, range: "7d" | "30d" | "qtd"): boolean {
const referenceDate = new Date(initiative.completedAt ?? initiative.dueDate).getTime();
if (range === "7d") {
return Math.abs(referenceDate - NOW) <= 7 * DAY_MS;
}
if (range === "30d") {
return Math.abs(referenceDate - NOW) <= 30 * DAY_MS;
}
const year = new Date(NOW).getUTCFullYear();
const quarterStart = Date.UTC(year, 0, 1);
const quarterEnd = Date.UTC(year, 2, 31, 23, 59, 59);
return referenceDate >= quarterStart && referenceDate <= quarterEnd;
}
function getSocialRange(range: "7d" | "30d" | "qtd"): SocialRange {
return range === "7d" ? "7d" : "30d";
}
function toMarketingRole(role: string): MarketingRole {
if (role === "lead" || role === "leader") {
return "lead";
}
if (role === "employee") {
return "employee";
}
if (role === "owner") {
return "owner";
}
return "employee";
}
function addDays(base: number, days: number): string {
return new Date(base + days * DAY_MS).toISOString().slice(0, 10);
}
function matchesLocationScope(initiative: Initiative, selectedLocationId: "all" | string): boolean {
if (selectedLocationId === "all") {
return true;
}
return initiative.isGlobal || initiative.locationIds.includes(selectedLocationId);
}
function isLimitedTimeOffer(initiative: Initiative): boolean {
const normalizedName = initiative.name.toLowerCase();
return (
initiative.type === "campania" ||
normalizedName.includes("limited time") ||
normalizedName.includes("lto") ||
normalizedName.includes("oferta limitada") ||
normalizedName.includes("oferta por tiempo")
);
}
export default function MarketingDepartmentPage() {
const { data: session } = useSession();
const pathname = usePathname();
const { locationId, dateRange, setLocationId, setDateRange } = useMarketingUIStore();
const initiativesSectionRef = useRef<HTMLDivElement | null>(null);
const meetingsSectionRef = useRef<HTMLDivElement | null>(null);
const [employees, setEmployees] = useState<Employee[]>(marketingMockData.employees);
const [initiatives, setInitiatives] = useState<Initiative[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [socialSnapshots, setSocialSnapshots] = useState<SocialSnapshot[]>(marketingMockData.socialSnapshots);
const [socialInsights, setSocialInsights] = useState<SocialInsight[]>([]);
const [metaConnection, setMetaConnection] = useState<MarketingMetaConnection | null>(null);
const [metaSyncStatus, setMetaSyncStatus] = useState<MetaSyncStatus>("disconnected");
const [latestSyncMessage, setLatestSyncMessage] = useState<string | null>(null);
const [isSyncingMeta, setIsSyncingMeta] = useState(false);
const [meetings, setMeetings] = useState<Meeting[]>(marketingMockData.meetings);
const [selectedInitiativeId, setSelectedInitiativeId] = useState<string | null>(null);
const [brandPulse, setBrandPulse] = useState<BrandPulse>(marketingMockData.brandPulse);
const [isLoadingMarketing, setIsLoadingMarketing] = useState(true);
const [isCreatingInitiative, setIsCreatingInitiative] = useState(false);
const [deletingInitiativeId, setDeletingInitiativeId] = useState<string | null>(null);
const [isSavingMeeting, setIsSavingMeeting] = useState(false);
const [meetingsError, setMeetingsError] = useState<string | null>(null);
const [marketingError, setMarketingError] = useState<string | null>(null);
const [marketingNotice, setMarketingNotice] = useState<{ kind: "success" | "error"; message: string } | null>(null);
const normalizedRole = toMarketingRole(session?.user?.role ?? "");
const employeesById = useMemo(() => Object.fromEntries(employees.map((employee) => [employee.id, employee])), [employees]);
const locationsById = useMemo(
() => Object.fromEntries(marketingMockData.locations.map((location) => [location.id, location])),
[]
);
const tasksById = useMemo(() => Object.fromEntries(tasks.map((task) => [task.id, task])), [tasks]);
const filteredInitiatives = useMemo(
() =>
initiatives.filter((initiative) => {
const matchesLocation = matchesLocationScope(initiative, locationId);
const matchesRange = matchesDateRange(initiative, dateRange);
return matchesLocation && matchesRange;
}),
[dateRange, initiatives, locationId]
);
const comparativeInitiatives = useMemo(() => {
const comparisonRange = dateRange === "7d" ? "30d" : "7d";
return initiatives.filter((initiative) => {
const matchesLocation = matchesLocationScope(initiative, locationId);
const matchesRange = matchesDateRange(initiative, comparisonRange);
return matchesLocation && matchesRange;
});
}, [dateRange, initiatives, locationId]);
const filteredTaskSet = useMemo(() => new Set(filteredInitiatives.map((initiative) => initiative.id)), [filteredInitiatives]);
const filteredTasks = useMemo(() => tasks.filter((task) => filteredTaskSet.has(task.initiativeId)), [filteredTaskSet, tasks]);
const participantOptions = useMemo(() => Array.from(new Set(employees.map((employee) => employee.name))), [employees]);
const initiativeScoresById = useMemo(
() =>
Object.fromEntries(
filteredInitiatives.map((initiative) => {
return [initiative.id, initiativeScore(initiative)];
})
),
[filteredInitiatives]
);
const limitedTimeOfferRanking = useMemo(
() =>
filteredInitiatives
.filter(isLimitedTimeOffer)
.map((initiative) => ({
initiative,
score: initiativeScoresById[initiative.id] ?? initiativeScore(initiative),
}))
.sort((a, b) => b.score - a.score),
[filteredInitiatives, initiativeScoresById]
);
const currentScore = useMemo(
() =>
departmentScore({
initiatives: filteredInitiatives,
socialSnapshots,
brandPulse,
socialRange: getSocialRange(dateRange),
}),
[brandPulse, dateRange, filteredInitiatives, socialSnapshots]
);
const comparisonScore = useMemo(
() =>
departmentScore({
initiatives: comparativeInitiatives,
socialSnapshots,
brandPulse,
socialRange: dateRange === "7d" ? "30d" : "7d",
}),
[brandPulse, comparativeInitiatives, dateRange, socialSnapshots]
);
const trendDelta = currentScore.total - comparisonScore.total;
const selectedInitiative = useMemo(
() => initiatives.find((initiative) => initiative.id === selectedInitiativeId) ?? null,
[initiatives, selectedInitiativeId]
);
useEffect(() => {
if (!selectedInitiativeId) {
return;
}
const stillExists = initiatives.some((initiative) => initiative.id === selectedInitiativeId);
if (!stillExists) {
setSelectedInitiativeId(null);
}
}, [initiatives, selectedInitiativeId]);
useEffect(() => {
if (pathname.startsWith("/departments/marketing/initiatives")) {
initiativesSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (pathname.startsWith("/departments/marketing/meetings")) {
meetingsSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [pathname]);
const subtitleDateRange = MARKETING_DATE_RANGE_LABELS[dateRange];
const brandPulseEditorName = brandPulse.updatedByName ?? employeesById[brandPulse.updatedById]?.name ?? "Lider de Marketing";
const loadMarketingData = useCallback(async (options?: { showLoading?: boolean }) => {
const shouldShowLoading = options?.showLoading ?? true;
if (shouldShowLoading) {
setIsLoadingMarketing(true);
}
try {
const response = await fetch("/api/marketing/dashboard", {
method: "GET",
cache: "no-store",
});
const payload = (await response.json()) as {
error?: string;
employees?: Employee[];
initiatives?: Initiative[];
tasks?: Task[];
socialSnapshots?: SocialSnapshot[];
socialInsights?: SocialInsight[];
brandPulse?: BrandPulse;
metaConnection?: MarketingMetaConnection | null;
metaSyncStatus?: MetaSyncStatus;
latestSyncMessage?: string | null;
};
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar Marketing.");
}
setEmployees(payload.employees ?? []);
setInitiatives(payload.initiatives ?? []);
setTasks(payload.tasks ?? []);
setSocialSnapshots(payload.socialSnapshots ?? marketingMockData.socialSnapshots);
setSocialInsights(payload.socialInsights ?? []);
setBrandPulse(payload.brandPulse ?? marketingMockData.brandPulse);
setMetaConnection(payload.metaConnection ?? null);
setMetaSyncStatus(payload.metaSyncStatus ?? "disconnected");
setLatestSyncMessage(payload.latestSyncMessage ?? null);
setMarketingError(null);
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo cargar Marketing.");
} finally {
if (shouldShowLoading) {
setIsLoadingMarketing(false);
}
}
}, []);
const loadMeetings = useCallback(async () => {
const response = await fetch("/api/marketing/meetings", {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudieron cargar reuniones.");
}
const payload = (await response.json()) as { meetings: Meeting[] };
setMeetings(payload.meetings);
setMeetingsError(null);
}, []);
useEffect(() => {
loadMarketingData().catch(() => {
// handled in callback
});
loadMeetings().catch((error) => {
setMeetingsError(error instanceof Error ? error.message : "No se pudieron cargar reuniones.");
});
}, [loadMarketingData, loadMeetings]);
const handleCreateInitiative = () => {
if (isCreatingInitiative) {
return;
}
const isGlobal = locationId === "all";
const locationScope = isGlobal ? [] : [locationId];
const defaultDueInDays = dateRange === "7d" ? 6 : dateRange === "30d" ? 14 : 21;
void (async () => {
setIsCreatingInitiative(true);
setMarketingError(null);
try {
const response = await fetch("/api/marketing/initiatives", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Nueva iniciativa",
type: "otro",
dueDate: addDays(NOW, defaultDueInDays),
isGlobal,
locationIds: locationScope,
}),
});
const payload = (await response.json()) as { error?: string; initiative?: Initiative };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear la iniciativa.");
}
if (payload.initiative?.id) {
setInitiatives((prev) => [payload.initiative!, ...prev.filter((initiative) => initiative.id !== payload.initiative!.id)]);
setSelectedInitiativeId(payload.initiative.id);
}
void loadMarketingData();
setMarketingError(null);
setMarketingNotice({
kind: "success",
message: `Iniciativa creada: ${payload.initiative?.name ?? "Nueva iniciativa"}.`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "No se pudo crear la iniciativa.";
setMarketingError(message);
setMarketingNotice({ kind: "error", message });
} finally {
setIsCreatingInitiative(false);
}
})();
};
const handleSaveInitiative = async (nextInitiative: Initiative): Promise<boolean> => {
try {
const response = await fetch(`/api/marketing/initiatives/${nextInitiative.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initiative: nextInitiative }),
});
const payload = (await response.json()) as { error?: string; initiative?: Initiative | null };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar la iniciativa.");
}
setInitiatives((prev) =>
prev.map((initiative) => (initiative.id === nextInitiative.id ? payload.initiative ?? nextInitiative : initiative))
);
void loadMarketingData({ showLoading: false });
setMarketingError(null);
setMarketingNotice({
kind: "success",
message: `Iniciativa guardada: ${payload.initiative?.name ?? nextInitiative.name}.`,
});
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "No se pudo guardar la iniciativa.";
setMarketingError(message);
setMarketingNotice({ kind: "error", message });
return false;
}
};
const handleDeleteInitiative = (initiativeId: string) => {
if (deletingInitiativeId === initiativeId) {
return;
}
const confirmed = window.confirm("¿Eliminar esta iniciativa? Esta acción también elimina tareas y milestones ligados.");
if (!confirmed) {
return;
}
void (async () => {
setDeletingInitiativeId(initiativeId);
setMarketingError(null);
try {
const response = await fetch(`/api/marketing/initiatives/${initiativeId}`, {
method: "DELETE",
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo eliminar la iniciativa.");
}
setInitiatives((prev) => prev.filter((initiative) => initiative.id !== initiativeId));
setTasks((prev) => prev.filter((task) => task.initiativeId !== initiativeId));
if (selectedInitiativeId === initiativeId) {
setSelectedInitiativeId(null);
}
void loadMarketingData();
setMarketingError(null);
setMarketingNotice({ kind: "success", message: "Iniciativa eliminada." });
} catch (error) {
const message = error instanceof Error ? error.message : "No se pudo eliminar la iniciativa.";
setMarketingError(message);
setMarketingNotice({ kind: "error", message });
} finally {
setDeletingInitiativeId(null);
}
})();
};
const handleUpdateTask = (taskId: string, patch: Partial<Pick<Task, "status" | "evidenceLinks">>) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const payload = (await response.json()) as { error?: string; task?: Task };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar la tarea.");
}
if (payload.task) {
setTasks((prev) => prev.map((task) => (task.id === payload.task?.id ? payload.task : task)));
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo actualizar la tarea.");
}
})();
};
const handleCreateTask = (input: { initiativeId: string; title: string; description: string; assigneeId: string; dueDate: string }) => {
void (async () => {
try {
const response = await fetch("/api/marketing/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const payload = (await response.json()) as { error?: string; task?: Task };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear la tarea.");
}
if (payload.task) {
setTasks((prev) => [...prev, payload.task as Task]);
setInitiatives((prev) =>
prev.map((initiative) =>
initiative.id === input.initiativeId
? {
...initiative,
taskIds: [...initiative.taskIds, payload.task!.id],
}
: initiative
)
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo crear la tarea.");
}
})();
};
const handleUpdateBrandPulse = (nextPulse: BrandPulse) => {
setBrandPulse(nextPulse);
void (async () => {
try {
const response = await fetch("/api/marketing/brand-pulse", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rating1to5: nextPulse.rating1to5,
notes: nextPulse.notes,
}),
});
const payload = (await response.json()) as { error?: string; brandPulse?: BrandPulse };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar Brand Pulse.");
}
if (payload.brandPulse) {
setBrandPulse(payload.brandPulse);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo actualizar Brand Pulse.");
}
})();
};
const handleCreateMilestone = (input: { initiativeId: string; title: string; description: string; dueDate: string }) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/initiatives/${input.initiativeId}/milestones`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const payload = (await response.json()) as { error?: string; milestone?: Milestone };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear el milestone.");
}
if (payload.milestone) {
setInitiatives((prev) =>
prev.map((initiative) => {
if (initiative.id !== input.initiativeId) {
return initiative;
}
const nextMilestones = [...(initiative.milestones ?? []), payload.milestone!].sort(
(a, b) => a.sortOrder - b.sortOrder
);
return {
...initiative,
milestones: nextMilestones,
};
})
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo crear el milestone.");
}
})();
};
const handleUpdateMilestone = (milestoneId: string, patch: { status?: "pending" | "in_progress" | "completed" }) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/milestones/${milestoneId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const payload = (await response.json()) as { error?: string; milestone?: Milestone };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo actualizar el milestone.");
}
if (payload.milestone) {
setInitiatives((prev) =>
prev.map((initiative) => {
const milestones = initiative.milestones ?? [];
if (!milestones.some((milestone) => milestone.id === payload.milestone?.id)) {
return initiative;
}
return {
...initiative,
milestones: milestones
.map((milestone) => (milestone.id === payload.milestone?.id ? payload.milestone! : milestone))
.sort((a, b) => a.sortOrder - b.sortOrder),
};
})
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo actualizar el milestone.");
}
})();
};
const handleAddMilestoneCheckpoint = (input: { milestoneId: string; note: string }) => {
void (async () => {
try {
const response = await fetch(`/api/marketing/milestones/${input.milestoneId}/checkpoints`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: input.note }),
});
const payload = (await response.json()) as { error?: string; milestone?: Milestone };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo registrar el checkpoint.");
}
if (payload.milestone) {
setInitiatives((prev) =>
prev.map((initiative) => {
const milestones = initiative.milestones ?? [];
if (!milestones.some((milestone) => milestone.id === payload.milestone?.id)) {
return initiative;
}
return {
...initiative,
milestones: milestones
.map((milestone) => (milestone.id === payload.milestone?.id ? payload.milestone! : milestone))
.sort((a, b) => a.sortOrder - b.sortOrder),
};
})
);
}
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo registrar el checkpoint.");
}
})();
};
const handleRequestMeeting = async (input: {
title: string;
agenda: string;
suggestedTimes: string[];
participantNames: string[];
}) => {
setIsSavingMeeting(true);
try {
const response = await fetch("/api/marketing/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "request",
...input,
}),
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudo solicitar reunión.");
}
await loadMeetings();
setMeetingsError(null);
} catch (error) {
setMeetingsError(error instanceof Error ? error.message : "No se pudo solicitar reunión.");
} finally {
setIsSavingMeeting(false);
}
};
const handleScheduleMeeting = async (input: {
title: string;
agenda: string;
scheduledFor: string;
participantNames: string[];
}) => {
setIsSavingMeeting(true);
try {
const response = await fetch("/api/marketing/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "schedule",
...input,
}),
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudo agendar reunión.");
}
await loadMeetings();
setMeetingsError(null);
} catch (error) {
setMeetingsError(error instanceof Error ? error.message : "No se pudo agendar reunión.");
} finally {
setIsSavingMeeting(false);
}
};
const handleCompleteMeeting = async (input: {
meetingId: string;
commitments: Array<{
title: string;
description?: string;
ownerName?: string;
dueDate?: string;
}>;
}) => {
setIsSavingMeeting(true);
try {
const response = await fetch(`/api/marketing/meetings/${input.meetingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "complete",
commitments: input.commitments,
}),
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "No se pudo completar reunión.");
}
await loadMeetings();
setMeetingsError(null);
} catch (error) {
setMeetingsError(error instanceof Error ? error.message : "No se pudo completar reunión.");
} finally {
setIsSavingMeeting(false);
}
};
const handleConnectMeta = async (input: {
accountId: string;
pageId: string;
pageName: string;
pageAccessToken: string;
tokenExpiresAt?: string | null;
}) => {
setMarketingError(null);
try {
const response = await fetch("/api/marketing/meta/connection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar la conexión de Meta.");
}
await loadMarketingData({ showLoading: false });
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo guardar la conexión de Meta.");
}
};
const handleDisconnectMeta = async () => {
setMarketingError(null);
try {
const response = await fetch("/api/marketing/meta/disconnect", {
method: "POST",
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo desconectar Meta.");
}
await loadMarketingData({ showLoading: false });
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo desconectar Meta.");
}
};
const handleSyncMeta = async () => {
setMarketingError(null);
setIsSyncingMeta(true);
try {
const response = await fetch("/api/marketing/meta/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo sincronizar Meta.");
}
await loadMarketingData({ showLoading: false });
} catch (error) {
setMarketingError(error instanceof Error ? error.message : "No se pudo sincronizar Meta.");
} finally {
setIsSyncingMeta(false);
}
};
return (
<div className="space-y-5">
<MarketingHeader
role={normalizedRole}
dateRange={dateRange}
locationId={locationId}
locations={marketingMockData.locations}
onDateRangeChange={setDateRange}
onLocationChange={setLocationId}
/>
<p className="text-label px-1 text-benell-text-soft">
Mostrando {filteredInitiatives.length} iniciativas para {locationId === "all" ? "todas las ubicaciones" : locationsById[locationId]?.name} · {subtitleDateRange}
</p>
{marketingError ? <p className="text-label text-benell-red">{marketingError}</p> : null}
{marketingNotice ? (
<p className={marketingNotice.kind === "success" ? "text-label text-benell-green" : "text-label text-benell-red"}>
{marketingNotice.message}
</p>
) : null}
{isLoadingMarketing ? <p className="text-label text-benell-text-soft">Cargando marketing...</p> : null}
<DepartmentKpiTracker
department="marketing"
title="KPIs del área: Marketing"
description="Define medición, evidencia, score y seguimiento semanal para los KPIs de Marketing."
/>
<HealthScoreCard score={currentScore} trendDelta={trendDelta} />
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-h2 font-semibold">Ranking Limited Time Offers</h2>
<p className="text-label text-benell-text-soft">Campañas/LTO visibles en el board</p>
</div>
{limitedTimeOfferRanking.length === 0 ? (
<p className="text-body mt-3 text-benell-text-soft">
No hay iniciativas LTO para los filtros activos. Usa tipo Campaña o incluye &quot;LTO&quot;/&quot;Limited Time&quot; en el nombre.
</p>
) : (
<div className="mt-3 overflow-x-auto">
<table className="min-w-[720px] w-full border-separate border-spacing-y-2">
<thead>
<tr className="text-left text-label text-benell-text-soft">
<th className="px-3 py-1">Rank</th>
<th className="px-3 py-1">Initiative</th>
<th className="px-3 py-1">Score</th>
<th className="px-3 py-1">Status</th>
<th className="px-3 py-1">Due</th>
</tr>
</thead>
<tbody>
{limitedTimeOfferRanking.map(({ initiative, score }, index) => (
<tr key={initiative.id} className="rounded-xl bg-white">
<td className="rounded-l-xl px-3 py-3 text-body font-semibold">#{index + 1}</td>
<td className="px-3 py-3 text-body">{initiative.name}</td>
<td className="px-3 py-3 text-body font-semibold text-tabular">{score.toFixed(1)}</td>
<td className="px-3 py-3 text-body">{initiative.status}</td>
<td className="rounded-r-xl px-3 py-3 text-body text-tabular">{initiative.dueDate}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div ref={meetingsSectionRef} className="grid gap-5 xl:grid-cols-2">
<SocialPanel
insights={socialInsights}
metaConnection={metaConnection}
metaSyncStatus={metaSyncStatus}
latestSyncMessage={latestSyncMessage}
onConnect={handleConnectMeta}
onDisconnect={handleDisconnectMeta}
onSync={handleSyncMeta}
isSyncing={isSyncingMeta}
/>
<BrandPulseCard
brandPulse={brandPulse}
role={normalizedRole}
updatedByName={brandPulseEditorName}
onUpdate={handleUpdateBrandPulse}
/>
</div>
<section ref={initiativesSectionRef} id="initiatives-section">
<InitiativeTable
initiatives={filteredInitiatives}
scoresById={initiativeScoresById}
employeesById={employeesById}
locationsById={locationsById}
selectedInitiativeId={selectedInitiativeId}
role={normalizedRole}
onSelectInitiative={setSelectedInitiativeId}
isCreatingInitiative={isCreatingInitiative}
onCreateInitiative={normalizedRole === "lead" || normalizedRole === "owner" ? handleCreateInitiative : undefined}
/>
</section>
<div className="grid gap-5 xl:grid-cols-2">
<div className="space-y-2">
{meetingsError ? <p className="text-label text-benell-red">{meetingsError}</p> : null}
<MeetingsWidget
meetings={meetings}
role={normalizedRole}
participantOptions={participantOptions}
isSaving={isSavingMeeting}
onRequestMeeting={handleRequestMeeting}
onScheduleMeeting={handleScheduleMeeting}
onCompleteMeeting={handleCompleteMeeting}
/>
</div>
<EmployeePerformanceTable
employees={employees}
initiatives={filteredInitiatives}
tasks={filteredTasks}
role={normalizedRole}
currentEmployeeId={session?.user?.id}
/>
</div>
<InitiativeDrawer
initiative={selectedInitiative}
tasksById={tasksById}
employeesById={employeesById}
locationsById={locationsById}
role={normalizedRole}
currentEmployeeId={session?.user?.id}
isOpen={Boolean(selectedInitiative)}
onClose={() => setSelectedInitiativeId(null)}
onSaveInitiative={handleSaveInitiative}
onDeleteInitiative={normalizedRole === "lead" || normalizedRole === "owner" ? handleDeleteInitiative : undefined}
isDeletingInitiative={selectedInitiativeId ? deletingInitiativeId === selectedInitiativeId : false}
onUpdateTask={handleUpdateTask}
onCreateTask={handleCreateTask}
onCreateMilestone={handleCreateMilestone}
onUpdateMilestone={handleUpdateMilestone}
onAddMilestoneCheckpoint={handleAddMilestoneCheckpoint}
/>
</div>
);
}