merca y ch
This commit is contained in:
1
src/app/(app)/departments/marketing/initiatives/page.tsx
Normal file
1
src/app/(app)/departments/marketing/initiatives/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../page";
|
||||
1
src/app/(app)/departments/marketing/meetings/page.tsx
Normal file
1
src/app/(app)/departments/marketing/meetings/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../page";
|
||||
935
src/app/(app)/departments/marketing/page.tsx
Normal file
935
src/app/(app)/departments/marketing/page.tsx
Normal 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 "LTO"/"Limited Time" 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user