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,5 @@
import { redirect } from "next/navigation";
export default function AdminDepartmentPage() {
redirect("/financial-flow");
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function FinanceDepartmentPage() {
redirect("/financial-flow");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,633 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSession } from "next-auth/react";
import type {
HumanCapitalFileRequirementDTO,
HumanCapitalWorkspaceTabConfig,
HumanCapitalWorkspaceTabKey,
HumanCapitalWorkspaceVersion,
HumanCapitalWorkspaceWidgetKey,
} from "@/lib/human-capital/types";
const TAB_CATALOG: Array<{
key: HumanCapitalWorkspaceTabKey;
label: string;
description: string;
widgets: HumanCapitalWorkspaceWidgetKey[];
}> = [
{
key: "resumen",
label: "Resumen",
description: "Score, tendencias y vision general de Capital Humano.",
widgets: ["summary_metrics"],
},
{
key: "expedientes",
label: "Expedientes",
description: "Checklist de documentos por persona y estatus de completitud.",
widgets: ["people_files_table"],
},
{
key: "nomina",
label: "Nomina",
description: "Percepciones, deducciones y aportaciones por periodo y ubicacion.",
widgets: ["payroll_summary"],
},
{
key: "reclutamiento",
label: "Reclutamiento",
description: "Headcount actual, vacantes y organizacion ideal por sucursal/rol.",
widgets: ["recruitment_headcount", "recruitment_vacancies", "recruitment_org_target"],
},
{
key: "desarrollo",
label: "Desarrollo",
description: "Anuncios internos y cursos para crecimiento de carrera.",
widgets: ["career_feed"],
},
{
key: "cumplimiento",
label: "Cumplimiento",
description: "IMSS, Infonavit, Fonacot y seguimiento de pagos.",
widgets: ["compliance_overview"],
},
];
const WIDGET_OPTIONS: Array<{ key: HumanCapitalWorkspaceWidgetKey; label: string }> = [
{ key: "summary_metrics", label: "Resumen y KPI" },
{ key: "people_files_table", label: "Tabla de expedientes" },
{ key: "payroll_summary", label: "Resumen de nomina" },
{ key: "recruitment_headcount", label: "Headcount" },
{ key: "recruitment_vacancies", label: "Vacantes" },
{ key: "recruitment_org_target", label: "Organizacion ideal" },
{ key: "career_feed", label: "Anuncios y cursos" },
{ key: "compliance_overview", label: "Cumplimiento y pagos" },
];
const FIELD_TYPE_OPTIONS = ["document", "text", "email", "phone", "date", "number"] as const;
type RequirementDraft = HumanCapitalFileRequirementDTO & { isNew?: boolean };
function normalizeOrder(tabs: HumanCapitalWorkspaceTabConfig[]) {
return tabs.map((tab, idx) => ({ ...tab, order: idx }));
}
function sortByOrder(tabs: HumanCapitalWorkspaceTabConfig[]) {
return [...tabs].sort((a, b) => a.order - b.order);
}
export default function HumanCapitalWorkspaceConfigPage() {
const { data: session } = useSession();
const canManage =
session?.user?.role === "owner" ||
(session?.user?.role === "leader" &&
(session?.user?.department === "capital_humano" || session?.user?.department === "administracion"));
const [tabs, setTabs] = useState<HumanCapitalWorkspaceTabConfig[]>([]);
const [history, setHistory] = useState<HumanCapitalWorkspaceVersion[]>([]);
const [requirements, setRequirements] = useState<RequirementDraft[]>([]);
const [changeSummary, setChangeSummary] = useState("");
const [newTabKey, setNewTabKey] = useState<HumanCapitalWorkspaceTabKey | "">("");
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const unusedTabs = useMemo(() => {
const used = new Set(tabs.map((tab) => tab.key));
return TAB_CATALOG.filter((tab) => !used.has(tab.key));
}, [tabs]);
useEffect(() => {
if (unusedTabs.length > 0) {
setNewTabKey(unusedTabs[0].key);
} else {
setNewTabKey("");
}
}, [unusedTabs]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!canManage) {
setLoading(false);
return;
}
try {
const [configResponse, historyResponse, requirementsResponse] = await Promise.all([
fetch("/api/human-capital/workspace/config?mode=draft", { method: "GET", cache: "no-store" }),
fetch("/api/human-capital/workspace/history", { method: "GET", cache: "no-store" }),
fetch("/api/human-capital/files/requirements", { method: "GET", cache: "no-store" }),
]);
const configPayload = (await configResponse.json()) as { config?: { tabs?: HumanCapitalWorkspaceTabConfig[] }; error?: string };
const historyPayload = (await historyResponse.json()) as { history?: HumanCapitalWorkspaceVersion[]; error?: string };
const requirementsPayload = (await requirementsResponse.json()) as { requirements?: HumanCapitalFileRequirementDTO[]; error?: string };
if (!configResponse.ok) {
throw new Error(configPayload.error ?? "No se pudo cargar configuracion.");
}
if (!historyResponse.ok) {
throw new Error(historyPayload.error ?? "No se pudo cargar historial.");
}
if (!requirementsResponse.ok) {
throw new Error(requirementsPayload.error ?? "No se pudieron cargar requisitos.");
}
if (!cancelled) {
setTabs(sortByOrder(configPayload.config?.tabs ?? []));
setHistory(historyPayload.history ?? []);
setRequirements(requirementsPayload.requirements ?? []);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar configuracion.");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [canManage]);
const updateTab = (index: number, updates: Partial<HumanCapitalWorkspaceTabConfig>) => {
setTabs((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...updates };
return next;
});
};
const moveTab = (index: number, direction: "up" | "down") => {
setTabs((prev) => {
const next = [...prev];
const target = direction === "up" ? index - 1 : index + 1;
if (target < 0 || target >= next.length) {
return prev;
}
const temp = next[index];
next[index] = next[target];
next[target] = temp;
return normalizeOrder(next);
});
};
const toggleWidget = (index: number, widget: HumanCapitalWorkspaceWidgetKey) => {
setTabs((prev) => {
const next = [...prev];
const widgets = new Set(next[index].widgets);
if (widgets.has(widget)) {
widgets.delete(widget);
} else {
widgets.add(widget);
}
next[index] = { ...next[index], widgets: Array.from(widgets) };
return next;
});
};
const addTab = () => {
if (!newTabKey) {
return;
}
const template = TAB_CATALOG.find((tab) => tab.key === newTabKey);
if (!template) {
return;
}
setTabs((prev) =>
normalizeOrder([
...prev,
{
key: template.key,
label: template.label,
description: template.description,
order: prev.length,
visible: true,
widgets: template.widgets,
},
])
);
};
const saveDraft = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/workspace/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tabs: normalizeOrder(tabs), changeSummary }),
});
const payload = (await response.json()) as { error?: string; config?: { tabs?: HumanCapitalWorkspaceTabConfig[] } };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar el borrador.");
}
setTabs(sortByOrder(payload.config?.tabs ?? []));
setStatus("Borrador guardado.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "No se pudo guardar el borrador.");
} finally {
setSaving(false);
}
};
const publishDraft = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/workspace/publish", { method: "POST" });
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo publicar.");
}
setStatus("Workspace publicado.");
const historyResponse = await fetch("/api/human-capital/workspace/history", { method: "GET", cache: "no-store" });
const historyPayload = (await historyResponse.json()) as { history?: HumanCapitalWorkspaceVersion[] };
setHistory(historyPayload.history ?? []);
} catch (publishError) {
setError(publishError instanceof Error ? publishError.message : "No se pudo publicar.");
} finally {
setSaving(false);
}
};
const loadPublished = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/workspace/config?mode=published", { method: "GET", cache: "no-store" });
const payload = (await response.json()) as { error?: string; config?: { tabs?: HumanCapitalWorkspaceTabConfig[] } };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar configuracion publicada.");
}
setTabs(sortByOrder(payload.config?.tabs ?? []));
setStatus("Configuracion publicada cargada en el editor.");
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar configuracion publicada.");
} finally {
setSaving(false);
}
};
const applyHistoryVersion = (version: HumanCapitalWorkspaceVersion) => {
setTabs(sortByOrder(version.tabs));
setChangeSummary(`Rollback a version ${version.version}`);
setStatus(`Version ${version.version} cargada en el editor.`);
};
const updateRequirement = (index: number, updates: Partial<RequirementDraft>) => {
setRequirements((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...updates };
return next;
});
};
const addRequirement = () => {
setRequirements((prev) => [
...prev,
{
id: "",
key: "",
label: "",
description: "",
fieldType: "document",
isRequired: true,
isActive: true,
sortOrder: prev.length * 10,
isNew: true,
},
]);
};
const saveRequirements = async () => {
setError(null);
setStatus(null);
setSaving(true);
try {
const response = await fetch("/api/human-capital/files/requirements", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requirements }),
});
const payload = (await response.json()) as { error?: string; requirements?: HumanCapitalFileRequirementDTO[] };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudieron guardar requisitos.");
}
setRequirements(payload.requirements ?? []);
setStatus("Requisitos guardados.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "No se pudieron guardar requisitos.");
} finally {
setSaving(false);
}
};
if (!canManage) {
return (
<div className="rounded-benell border border-benell-stroke bg-benell-surface p-6 shadow-benell">
<h1 className="text-h2 font-semibold">Configuracion de Capital Humano</h1>
<p className="text-body text-benell-text-soft">Solo lideres pueden editar el workspace.</p>
</div>
);
}
return (
<div className="space-y-6">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h1 className="text-display">Configuracion de Capital Humano</h1>
<p className="text-body-lg text-benell-text-soft">Administra tabs, widgets y requisitos sin hardcode.</p>
</header>
{loading ? <p className="text-label text-benell-text-soft">Cargando configuracion...</p> : null}
{error ? <p className="text-label text-benell-red">{error}</p> : null}
{status ? <p className="text-label text-benell-text-soft">{status}</p> : null}
<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-3">
<div>
<h2 className="text-h2 font-semibold">Tabs y widgets</h2>
<p className="text-body text-benell-text-soft">Define visibilidad, orden y contenido de cada tab.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void saveDraft()}
disabled={saving}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar borrador
</button>
<button
type="button"
onClick={() => void publishDraft()}
disabled={saving}
className="rounded-pill border border-benell-brown px-3 py-2 text-label font-semibold text-benell-brown disabled:opacity-50"
>
Publicar
</button>
<button
type="button"
onClick={() => void loadPublished()}
disabled={saving}
className="rounded-pill border border-benell-stroke px-3 py-2 text-label font-semibold text-benell-text-soft disabled:opacity-50"
>
Cargar publicado
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4">
{tabs.map((tab, index) => (
<article key={tab.key} className="rounded-benell border border-benell-stroke bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[220px]">
<label className="text-xs font-semibold text-benell-text-soft">Titulo</label>
<input
value={tab.label}
onChange={(event) => updateTab(index, { label: event.target.value })}
className="mt-1 w-full rounded-lg border border-benell-stroke px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 min-w-[220px]">
<label className="text-xs font-semibold text-benell-text-soft">Descripcion</label>
<input
value={tab.description ?? ""}
onChange={(event) => updateTab(index, { description: event.target.value })}
className="mt-1 w-full rounded-lg border border-benell-stroke px-3 py-2 text-sm"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={tab.visible}
onChange={(event) => updateTab(index, { visible: event.target.checked })}
/>
Visible
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => moveTab(index, "up")}
disabled={index === 0}
className="rounded-lg border border-benell-stroke px-2 py-1 text-xs text-benell-text-soft disabled:opacity-50"
>
Subir
</button>
<button
type="button"
onClick={() => moveTab(index, "down")}
disabled={index === tabs.length - 1}
className="rounded-lg border border-benell-stroke px-2 py-1 text-xs text-benell-text-soft disabled:opacity-50"
>
Bajar
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2">
{WIDGET_OPTIONS.map((widget) => (
<label key={widget.key} className="flex items-center gap-2 text-sm text-benell-text">
<input
type="checkbox"
checked={tab.widgets.includes(widget.key)}
onChange={() => toggleWidget(index, widget.key)}
/>
{widget.label}
</label>
))}
</div>
</article>
))}
</div>
{unusedTabs.length > 0 ? (
<div className="mt-4 flex flex-wrap items-center gap-3">
<select
value={newTabKey}
onChange={(event) => setNewTabKey(event.target.value as HumanCapitalWorkspaceTabKey)}
className="rounded-lg border border-benell-stroke px-3 py-2 text-sm"
>
{unusedTabs.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
<button
type="button"
onClick={addTab}
className="rounded-pill border border-benell-stroke px-3 py-2 text-label font-semibold text-benell-text"
>
Agregar tab
</button>
</div>
) : null}
<div className="mt-4">
<label className="text-xs font-semibold text-benell-text-soft">Resumen del cambio</label>
<input
value={changeSummary}
onChange={(event) => setChangeSummary(event.target.value)}
placeholder="Ej: Ajuste de tabs por reclutamiento"
className="mt-1 w-full rounded-lg border border-benell-stroke px-3 py-2 text-sm"
/>
</div>
</section>
<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-3">
<div>
<h2 className="text-h2 font-semibold">Requisitos de expediente</h2>
<p className="text-body text-benell-text-soft">Define documentos obligatorios y opcionales.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={addRequirement}
className="rounded-pill border border-benell-stroke px-3 py-2 text-label font-semibold text-benell-text"
>
Agregar requisito
</button>
<button
type="button"
onClick={() => void saveRequirements()}
disabled={saving}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar requisitos
</button>
</div>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[960px] text-sm">
<thead>
<tr className="border-b border-benell-stroke text-left text-label text-benell-text-soft">
<th className="px-2 py-2">Key</th>
<th className="px-2 py-2">Etiqueta</th>
<th className="px-2 py-2">Descripcion</th>
<th className="px-2 py-2">Tipo</th>
<th className="px-2 py-2">Requerido</th>
<th className="px-2 py-2">Activo</th>
<th className="px-2 py-2">Orden</th>
</tr>
</thead>
<tbody>
{requirements.map((req, index) => (
<tr key={`${req.id || "new"}-${index}`} className="border-b border-benell-stroke/70">
<td className="px-2 py-2">
<input
value={req.key}
onChange={(event) => updateRequirement(index, { key: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
placeholder="curp"
/>
</td>
<td className="px-2 py-2">
<input
value={req.label}
onChange={(event) => updateRequirement(index, { label: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
placeholder="CURP"
/>
</td>
<td className="px-2 py-2">
<input
value={req.description ?? ""}
onChange={(event) => updateRequirement(index, { description: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
placeholder="Detalle opcional"
/>
</td>
<td className="px-2 py-2">
<select
value={req.fieldType}
onChange={(event) => updateRequirement(index, { fieldType: event.target.value })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
>
{FIELD_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</td>
<td className="px-2 py-2">
<input
type="checkbox"
checked={req.isRequired}
onChange={(event) => updateRequirement(index, { isRequired: event.target.checked })}
/>
</td>
<td className="px-2 py-2">
<input
type="checkbox"
checked={req.isActive}
onChange={(event) => updateRequirement(index, { isActive: event.target.checked })}
/>
</td>
<td className="px-2 py-2">
<input
type="number"
value={req.sortOrder}
onChange={(event) => updateRequirement(index, { sortOrder: Number(event.target.value) })}
className="w-full rounded border border-benell-stroke px-2 py-1 text-xs"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h2 className="text-h2 font-semibold">Historial</h2>
<p className="text-body text-benell-text-soft">Versiones publicadas y borradores recientes.</p>
<div className="mt-4 grid grid-cols-1 gap-3">
{history.map((version) => (
<div key={version.id} className="rounded-benell border border-benell-stroke bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-benell-text">Version {version.version}</p>
<p className="text-caption text-benell-text-soft">
Estado: {version.status} · Publicado: {version.publishedAt ?? "-"}
</p>
</div>
<button
type="button"
onClick={() => applyHistoryVersion(version)}
className="rounded-pill border border-benell-stroke px-3 py-1 text-xs font-semibold text-benell-text"
>
Cargar en borrador
</button>
</div>
{version.changeSummary ? (
<p className="mt-2 text-xs text-benell-text-soft">{version.changeSummary}</p>
) : null}
</div>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import HumanCapitalWorkspaceTabs from "@/components/departments/HumanCapitalWorkspaceTabs";
export default function HumanCapitalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-5">
<HumanCapitalWorkspaceTabs />
{children}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HumanCapitalIndexPage() {
redirect("/departments/human-capital/resumen");
}

View File

@@ -0,0 +1,14 @@
import DepartmentWorkspaceTabs from "@/components/layout/DepartmentWorkspaceTabs";
export default function DepartmentsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<DepartmentWorkspaceTabs />
{children}
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,521 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { useSession } from "next-auth/react";
import DepartmentKpiTracker from "@/components/kpis/DepartmentKpiTracker";
import type { OperationsDashboardResponse } from "@/lib/operations";
type AssetPayload = {
name: string;
category: string;
criticality: number;
};
type WorkOrderPayload = {
assetId: string;
title: string;
dueBy: string;
estimatedCost: number;
expectedDowntimeHours: number;
};
type ForecastPayload = {
lastWeekSales: number;
multiplier: number;
};
type PlanPayload = {
lineName: string;
plannedUnits: number;
capacityUnits: number;
};
type AssetOption = {
id: string;
name: string;
assetCode: string;
};
export default function OperationsDepartmentPage() {
const { data: session } = useSession();
const [dashboard, setDashboard] = useState<OperationsDashboardResponse | null>(null);
const [assets, setAssets] = useState<AssetOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [assetDraft, setAssetDraft] = useState<AssetPayload>({ name: "", category: "", criticality: 3 });
const [workOrderDraft, setWorkOrderDraft] = useState<WorkOrderPayload>({
assetId: "",
title: "",
dueBy: "",
estimatedCost: 0,
expectedDowntimeHours: 0,
});
const [forecastDraft, setForecastDraft] = useState<ForecastPayload>({ lastWeekSales: 0, multiplier: 1.2 });
const [planDraft, setPlanDraft] = useState<PlanPayload>({ lineName: "Línea principal", plannedUnits: 0, capacityUnits: 0 });
const canManage = session?.user?.role === "owner" || (session?.user?.role === "leader" && session?.user?.department === "operaciones");
const isOwner = session?.user?.role === "owner";
const load = async () => {
setIsLoading(true);
setError(null);
try {
const [dashboardResponse, assetsResponse] = await Promise.all([
fetch("/api/operations/dashboard", { method: "GET", cache: "no-store" }),
fetch("/api/operations/assets", { method: "GET", cache: "no-store" }),
]);
const dashboardPayload = (await dashboardResponse.json()) as { error?: string } & OperationsDashboardResponse;
if (!dashboardResponse.ok) {
throw new Error(dashboardPayload.error ?? "No se pudo cargar Operaciones.");
}
const assetsPayload = (await assetsResponse.json()) as { error?: string; assets?: Array<{ id: string; name: string; assetCode: string }> };
if (!assetsResponse.ok) {
throw new Error(assetsPayload.error ?? "No se pudieron cargar activos.");
}
setDashboard(dashboardPayload);
const nextAssets = (assetsPayload.assets ?? []).map((asset) => ({ id: asset.id, name: asset.name, assetCode: asset.assetCode }));
setAssets(nextAssets);
setWorkOrderDraft((prev) => ({ ...prev, assetId: prev.assetId || nextAssets[0]?.id || "" }));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Operaciones.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const submitAsset = async () => {
if (!assetDraft.name.trim() || !assetDraft.category.trim()) {
return;
}
setError(null);
try {
const response = await fetch("/api/operations/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(assetDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear activo.");
}
setAssetDraft({ name: "", category: "", criticality: 3 });
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear activo.");
}
};
const submitWorkOrder = async () => {
if (!workOrderDraft.assetId || !workOrderDraft.title.trim()) {
return;
}
setError(null);
try {
const response = await fetch("/api/operations/work-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(workOrderDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear work order.");
}
setWorkOrderDraft((prev) => ({
...prev,
title: "",
dueBy: "",
estimatedCost: 0,
expectedDowntimeHours: 0,
}));
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear work order.");
}
};
const submitForecast = async () => {
setError(null);
try {
const response = await fetch("/api/operations/forecast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(forecastDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo guardar forecast.");
}
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo guardar forecast.");
}
};
const submitPlan = async () => {
if (!planDraft.lineName.trim()) {
return;
}
setError(null);
try {
const response = await fetch("/api/operations/production-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(planDraft),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear plan de producción.");
}
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear plan de producción.");
}
};
const publishKpis = async () => {
setError(null);
setIsPublishing(true);
try {
const response = await fetch("/api/operations/kpi/publish", { method: "POST" });
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo publicar KPI de operaciones.");
}
await load();
} catch (publishError) {
setError(publishError instanceof Error ? publishError.message : "No se pudo publicar KPI de operaciones.");
} finally {
setIsPublishing(false);
}
};
const resolveApproval = async (approvalId: string, action: "approve" | "reject") => {
setError(null);
try {
const response = await fetch(`/api/operations/approvals/${approvalId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo procesar la aprobación.");
}
await load();
} catch (approvalError) {
setError(approvalError instanceof Error ? approvalError.message : "No se pudo procesar la aprobación.");
}
};
const workOrderChart = useMemo(() => dashboard?.workOrdersByState ?? [], [dashboard]);
return (
<div className="space-y-5">
<header className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-display">Operaciones · Mantenimiento y Fábrica</h1>
<p className="text-body-lg text-benell-text-soft">Activos, PM, aprobaciones y planeación de producción basada en forecast.</p>
</div>
<button
type="button"
disabled={!canManage || isPublishing}
onClick={() => void publishKpis()}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
{isPublishing ? "Publicando..." : "Publicar KPI owner"}
</button>
</div>
</header>
{error ? <p className="text-label text-benell-red">{error}</p> : null}
{isLoading ? <p className="text-label text-benell-text-soft">Cargando Operaciones...</p> : null}
<DepartmentKpiTracker
department="operaciones"
title="KPIs del área: Operaciones"
description="Centraliza definición, evidencia, score y actualización de KPIs para Compras, Mantenimiento y CEDIS."
/>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Activos</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.totalAssets ?? 0}</p>
<p className="text-caption text-benell-text-soft">
Críticos: {dashboard?.summary.criticalAssets ?? 0} · Down: {dashboard?.summary.downAssets ?? 0}
</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Work orders abiertas</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.openWorkOrders ?? 0}</p>
<p className="text-caption text-benell-text-soft">Overdue: {dashboard?.summary.overdueWorkOrders ?? 0}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Aprobaciones pendientes</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.pendingApprovals ?? 0}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Forecast (semana)</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.forecastUnits ?? 0}</p>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-label text-benell-text-soft">Plan vs forecast</p>
<p className="text-h2 mt-1 font-semibold text-benell-text">{dashboard?.summary.actualVarianceUnits ?? 0}</p>
<p className="text-caption text-benell-text-soft">Plan: {dashboard?.summary.plannedUnits ?? 0}</p>
</article>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Work orders por estado</h2>
<div className="mt-3 h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={workOrderChart}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="state" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#8a6a3d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Aprobación owner inbox</h2>
<ul className="mt-3 space-y-2">
{(dashboard?.approvalInbox ?? []).map((approval) => (
<li key={approval.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-sm font-semibold text-benell-text">{approval.requestType}</p>
<p className="text-caption text-benell-text-soft">
WO: {approval.workOrderId ?? "-"} · Plan: {approval.productionPlanId ?? "-"}
</p>
<p className="text-caption text-benell-text-soft">{approval.state} · {new Date(approval.createdAt).toLocaleString("es-MX")}</p>
{approval.reason ? <p className="text-caption text-benell-text-soft">{approval.reason}</p> : null}
{isOwner && (approval.state === "pending_owner" || approval.state === "submitted") ? (
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void resolveApproval(approval.id, "approve")}
className="rounded-pill bg-benell-green px-3 py-1.5 text-label font-semibold text-white"
>
Aprobar
</button>
<button
type="button"
onClick={() => void resolveApproval(approval.id, "reject")}
className="rounded-pill bg-benell-red px-3 py-1.5 text-label font-semibold text-white"
>
Rechazar
</button>
</div>
) : null}
</li>
))}
</ul>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">PM próximo y vencido</h2>
<ul className="mt-3 space-y-2">
{(dashboard?.upcomingMaintenance ?? []).map((item) => (
<li key={item.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-sm font-semibold text-benell-text">{item.assetName} · {item.title}</p>
<p className="text-caption text-benell-text-soft">
Due: {new Date(item.dueBy).toLocaleDateString("es-MX")} · Estado: {item.state}
</p>
</li>
))}
</ul>
</article>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Nuevo activo</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<input
value={assetDraft.name}
onChange={(event) => setAssetDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nombre del activo"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
value={assetDraft.category}
onChange={(event) => setAssetDraft((prev) => ({ ...prev, category: event.target.value }))}
placeholder="Categoría"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
min={1}
max={5}
value={assetDraft.criticality}
onChange={(event) => setAssetDraft((prev) => ({ ...prev, criticality: Number(event.target.value) || 3 }))}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitAsset()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Crear activo
</button>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Nuevo work order</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<select
value={workOrderDraft.assetId}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, assetId: event.target.value }))}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
>
<option value="">Selecciona activo</option>
{assets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.name} ({asset.assetCode})
</option>
))}
</select>
<input
value={workOrderDraft.title}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Título"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="date"
value={workOrderDraft.dueBy}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, dueBy: event.target.value }))}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={workOrderDraft.estimatedCost}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, estimatedCost: Number(event.target.value) || 0 }))}
placeholder="Costo estimado"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={workOrderDraft.expectedDowntimeHours}
onChange={(event) => setWorkOrderDraft((prev) => ({ ...prev, expectedDowntimeHours: Number(event.target.value) || 0 }))}
placeholder="Downtime esperado (h)"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitWorkOrder()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Crear work order
</button>
</div>
</article>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Forecast semanal (manual)</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<input
type="number"
value={forecastDraft.lastWeekSales}
onChange={(event) => setForecastDraft((prev) => ({ ...prev, lastWeekSales: Number(event.target.value) || 0 }))}
placeholder="Ventas semana pasada"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
step="0.1"
value={forecastDraft.multiplier}
onChange={(event) => setForecastDraft((prev) => ({ ...prev, multiplier: Number(event.target.value) || 1.2 }))}
placeholder="Multiplicador"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitForecast()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Guardar forecast
</button>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<h2 className="text-h2 font-semibold">Plan de producción</h2>
<div className="mt-3 grid grid-cols-1 gap-2">
<input
value={planDraft.lineName}
onChange={(event) => setPlanDraft((prev) => ({ ...prev, lineName: event.target.value }))}
placeholder="Línea / célula"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={planDraft.plannedUnits}
onChange={(event) => setPlanDraft((prev) => ({ ...prev, plannedUnits: Number(event.target.value) || 0 }))}
placeholder="Unidades planeadas"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<input
type="number"
value={planDraft.capacityUnits}
onChange={(event) => setPlanDraft((prev) => ({ ...prev, capacityUnits: Number(event.target.value) || 0 }))}
placeholder="Capacidad"
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm"
disabled={!canManage}
/>
<button
type="button"
onClick={() => void submitPlan()}
disabled={!canManage}
className="rounded-pill bg-benell-brown px-3 py-2 text-label font-semibold text-white disabled:opacity-50"
>
Crear plan
</button>
</div>
</article>
</section>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDepartmentHomeRoute } from "@/lib/access-control";
import { DEPARTMENT_OPTIONS } from "@/lib/departments";
import type { DepartmentKey } from "@/lib/types";
export default async function DepartmentsHubPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== "owner") {
redirect(getDepartmentHomeRoute((session.user.department as DepartmentKey | null | undefined) ?? null));
}
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Departamento</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Selecciona un departamento</h1>
<p className="mt-1 text-sm text-benell-text-soft">Elige una vista para revisar indicadores, reuniones y ejecución por área.</p>
</section>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{DEPARTMENT_OPTIONS.map((option) => {
const href = getDepartmentHomeRoute(option.value);
return (
<Link
key={option.value}
href={href}
className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell transition hover:border-benell-brown/50"
>
<p className="text-sm font-semibold text-benell-text">{option.label}</p>
<p className="mt-1 text-xs text-benell-text-soft">Entrar a vista de {option.label.toLowerCase()}.</p>
</Link>
);
})}
</section>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import CaptureWorkspace from "@/components/capture/CaptureWorkspace";
export default function ProjectsCapturePage() {
return <CaptureWorkspace forcedDepartment="proyectos" />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ProjectsInitiativesAliasRedirect() {
redirect("/departments/projects/projects");
}

View File

@@ -0,0 +1,14 @@
import ProjectsWorkspaceTabs from "@/components/departments/ProjectsWorkspaceTabs";
export default function ProjectsDepartmentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="space-y-5">
<ProjectsWorkspaceTabs />
{children}
</div>
);
}

View File

@@ -0,0 +1,615 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { useSession } from "next-auth/react";
import { cn } from "@/lib/utils";
import { formatDate, formatDateTime, getStatusPillClass } from "@/lib/marketing/labels";
import type { Employee, Meeting, ProjectCalendarEvent } from "@/lib/projects/types";
type MeetingFilter = "all" | "mine" | "team" | "pending";
type CreateMode = "meeting" | "personal_event";
const SLOT_START_HOUR = 7;
const SLOT_END_HOUR = 21;
function startOfWeekMonday(source: Date): Date {
const date = new Date(source);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
date.setDate(date.getDate() + diff);
date.setHours(0, 0, 0, 0);
return date;
}
function addDays(base: Date, days: number): Date {
const next = new Date(base);
next.setDate(base.getDate() + days);
return next;
}
function formatDateTimeLocalInput(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
const hours = String(value.getHours()).padStart(2, "0");
const minutes = String(value.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function toIsoOrNull(value: string): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString();
}
function meetingIsMine(meeting: Meeting, userId: string): boolean {
if (meeting.requestedById === userId) {
return true;
}
return meeting.participants.some((participant) => participant.userId === userId);
}
function meetingHasPendingResponse(meeting: Meeting, userId: string): boolean {
return meeting.participants.some((participant) => participant.userId === userId && participant.responseStatus === "pending");
}
function canManageProjectsWorkspace(role: string | undefined, department: string | null | undefined): boolean {
if (role === "owner") {
return true;
}
return (role === "leader" || role === "lead") && department === "proyectos";
}
export default function ProjectsMeetingsPage() {
const { data: session } = useSession();
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeekMonday(new Date()));
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [events, setEvents] = useState<ProjectCalendarEvent[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [filter, setFilter] = useState<MeetingFilter>("all");
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createMode, setCreateMode] = useState<CreateMode>("meeting");
const [title, setTitle] = useState("");
const [agenda, setAgenda] = useState("");
const [startAtInput, setStartAtInput] = useState(() => {
const base = addDays(startOfWeekMonday(new Date()), 1);
base.setHours(9, 0, 0, 0);
return formatDateTimeLocalInput(base);
});
const [endAtInput, setEndAtInput] = useState(() => {
const base = addDays(startOfWeekMonday(new Date()), 1);
base.setHours(10, 0, 0, 0);
return formatDateTimeLocalInput(base);
});
const [visibility, setVisibility] = useState<"personal" | "team">("personal");
const [selectedParticipantIds, setSelectedParticipantIds] = useState<string[]>([]);
const weekDays = useMemo(() => Array.from({ length: 7 }, (_, dayOffset) => addDays(weekStart, dayOffset)), [weekStart]);
const weekStartIso = weekDays[0].toISOString();
const weekEnd = addDays(weekStart, 7);
const weekEndIso = weekEnd.toISOString();
const timeSlots = useMemo(() => {
const slots: Array<{ key: string; label: string }> = [];
for (let hour = SLOT_START_HOUR; hour < SLOT_END_HOUR; hour += 1) {
for (const minute of [0, 30]) {
const key = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
const label = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
slots.push({ key, label });
}
}
return slots;
}, []);
const loadMeetingsWorkspace = useCallback(async () => {
setIsLoading(true);
try {
const [meetingsResponse, eventsResponse, dashboardResponse] = await Promise.all([
fetch("/api/projects/meetings", { method: "GET", cache: "no-store" }),
fetch(`/api/projects/calendar/events?start=${encodeURIComponent(weekStartIso)}&end=${encodeURIComponent(weekEndIso)}`, {
method: "GET",
cache: "no-store",
}),
fetch("/api/projects/dashboard", { method: "GET", cache: "no-store" }),
]);
const meetingsPayload = (await meetingsResponse.json()) as { error?: string; meetings?: Meeting[] };
if (!meetingsResponse.ok) {
throw new Error(meetingsPayload.error ?? "No se pudieron cargar reuniones.");
}
const eventsPayload = (await eventsResponse.json()) as { error?: string; events?: ProjectCalendarEvent[] };
if (!eventsResponse.ok) {
throw new Error(eventsPayload.error ?? "No se pudieron cargar eventos de calendario.");
}
const dashboardPayload = (await dashboardResponse.json()) as { error?: string; employees?: Employee[] };
if (!dashboardResponse.ok) {
throw new Error(dashboardPayload.error ?? "No se pudieron cargar participantes.");
}
setMeetings(meetingsPayload.meetings ?? []);
setEvents(eventsPayload.events ?? []);
setEmployees(dashboardPayload.employees ?? []);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar el calendario de reuniones.");
} finally {
setIsLoading(false);
}
}, [weekEndIso, weekStartIso]);
useEffect(() => {
void loadMeetingsWorkspace();
}, [loadMeetingsWorkspace]);
const userId = session?.user?.id ?? "";
const canManage = canManageProjectsWorkspace(session?.user?.role, session?.user?.department);
useEffect(() => {
if (canManage) {
return;
}
if (createMode === "meeting") {
setCreateMode("personal_event");
}
if (visibility === "team") {
setVisibility("personal");
}
}, [canManage, createMode, visibility]);
const filteredMeetings = useMemo(() => {
if (!userId) {
return meetings;
}
if (filter === "mine") {
return meetings.filter((meeting) => meetingIsMine(meeting, userId));
}
if (filter === "pending") {
return meetings.filter((meeting) => meetingHasPendingResponse(meeting, userId));
}
if (filter === "team") {
return meetings.filter((meeting) => meeting.status !== "cancelled");
}
return meetings;
}, [filter, meetings, userId]);
const meetingsById = useMemo(() => Object.fromEntries(filteredMeetings.map((meeting) => [meeting.id, meeting])), [filteredMeetings]);
const calendarItems = useMemo(() => {
const items = events
.map((event) => {
const meeting = event.meetingId ? meetingsById[event.meetingId] : undefined;
if (event.meetingId && !meeting) {
return null;
}
return {
id: event.id,
title: meeting?.title ?? event.title,
startAt: event.startAt,
endAt: event.endAt,
meetingId: event.meetingId,
status: meeting?.status ?? null,
visibility: event.visibility,
mine: event.ownerUserId === userId || Boolean(meeting && meetingIsMine(meeting, userId)),
pending: Boolean(meeting && meetingHasPendingResponse(meeting, userId)),
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
return items.filter((item) => {
if (filter === "mine") {
return item.mine;
}
if (filter === "team") {
return item.visibility === "team" || Boolean(item.meetingId);
}
if (filter === "pending") {
return item.pending;
}
return true;
});
}, [events, filter, meetingsById, userId]);
const slotAssignments = useMemo(() => {
const map = new Map<string, typeof calendarItems>();
for (const item of calendarItems) {
const date = new Date(item.startAt);
const dayKey = date.toISOString().slice(0, 10);
const slotKey = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const key = `${dayKey}_${slotKey}`;
const current = map.get(key) ?? [];
current.push(item);
map.set(key, current);
}
return map;
}, [calendarItems]);
const weekTitle = `${formatDate(weekDays[0].toISOString())} - ${formatDate(weekDays[6].toISOString())}`;
const handleCreate = async () => {
const startAt = toIsoOrNull(startAtInput);
const endAt = toIsoOrNull(endAtInput);
if (!title.trim() || !startAt || !endAt) {
setError("Completa título, inicio y fin.");
return;
}
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
setError("La hora de fin debe ser posterior al inicio.");
return;
}
setIsSaving(true);
try {
if (createMode === "meeting") {
if (!canManage) {
throw new Error("Solo owners o líderes pueden crear reuniones de equipo.");
}
const response = await fetch("/api/projects/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "schedule",
title,
agenda,
startAt,
endAt,
participants: selectedParticipantIds.map((participantId) => ({ userId: participantId })),
}),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear la reunión.");
}
} else {
const response = await fetch("/api/projects/calendar/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
notes: agenda,
startAt,
endAt,
visibility: canManage ? visibility : "personal",
}),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo crear el evento personal.");
}
}
setTitle("");
setAgenda("");
setSelectedParticipantIds([]);
setError(null);
await loadMeetingsWorkspace();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "No se pudo guardar el evento.");
} finally {
setIsSaving(false);
}
};
const handleRespond = async (meetingId: string, responseStatus: "accepted" | "declined") => {
setIsSaving(true);
try {
const response = await fetch(`/api/projects/meetings/${meetingId}/respond`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responseStatus }),
});
const payload = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo responder la invitación.");
}
await loadMeetingsWorkspace();
} catch (respondError) {
setError(respondError instanceof Error ? respondError.message : "No se pudo responder la invitación.");
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Projects Meetings</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Calendario semanal</h1>
<p className="mt-1 text-sm text-benell-text-soft">Planeación de reuniones y eventos personales/equipo.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-benell-stroke px-3 py-1.5 text-sm font-semibold text-benell-text"
onClick={() => setWeekStart((prev) => addDays(prev, -7))}
>
<ChevronLeft className="size-4" aria-hidden /> Semana anterior
</button>
<span className="rounded-lg border border-benell-stroke bg-white px-3 py-1.5 text-sm text-benell-text">{weekTitle}</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-benell-stroke px-3 py-1.5 text-sm font-semibold text-benell-text"
onClick={() => setWeekStart((prev) => addDays(prev, 7))}
>
Semana siguiente <ChevronRight className="size-4" aria-hidden />
</button>
</div>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-wrap items-center gap-2">
{([
["all", "All"],
["mine", "Mine"],
["team", "Team"],
["pending", "Pending response"],
] as Array<[MeetingFilter, string]>).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setFilter(key)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wide",
filter === key ? "border-benell-brown bg-benell-brown text-white" : "border-benell-stroke bg-white text-benell-text"
)}
>
{label}
</button>
))}
</div>
</section>
{error ? <section className="rounded-benell border border-benell-red/40 bg-benell-red/10 p-4 text-sm text-benell-red">{error}</section> : null}
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-4 flex items-center gap-2">
<CalendarClock className="size-4 text-benell-brown" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Semana (07:00 - 21:00, intervalos de 30 min)</h2>
</header>
<div className="overflow-x-auto">
<div className="grid min-w-[900px] grid-cols-[84px_repeat(7,minmax(100px,1fr))] border border-benell-stroke">
<div className="border-b border-r border-benell-stroke bg-benell-surface-muted px-2 py-2 text-xs font-semibold uppercase text-benell-text-soft">
Hora
</div>
{weekDays.map((day) => (
<div key={day.toISOString()} className="border-b border-r border-benell-stroke bg-benell-surface-muted px-2 py-2 text-xs font-semibold uppercase text-benell-text-soft">
{new Intl.DateTimeFormat("es-MX", { weekday: "short", day: "2-digit", month: "short" }).format(day)}
</div>
))}
{timeSlots.map((slot) => (
<div key={`row_${slot.key}`} className="contents">
<div className="border-r border-t border-benell-stroke bg-white px-2 py-2 text-xs text-benell-text-soft">
{slot.label}
</div>
{weekDays.map((day) => {
const dayKey = day.toISOString().slice(0, 10);
const key = `${dayKey}_${slot.key}`;
const slotItems = slotAssignments.get(key) ?? [];
return (
<div key={key} className="min-h-[52px] border-r border-t border-benell-stroke bg-white p-1">
{slotItems.map((item) => (
<div
key={item.id}
className={cn(
"mb-1 rounded-md border px-2 py-1 text-[11px]",
item.meetingId ? "border-benell-brown/40 bg-benell-brown/10 text-benell-brown" : "border-benell-stroke bg-benell-surface-muted text-benell-text"
)}
>
<p className="line-clamp-2 font-semibold">{item.title}</p>
<p className="text-[10px]">{new Date(item.startAt).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" })}</p>
</div>
))}
</div>
);
})}
</div>
))}
</div>
</div>
</section>
<section className="grid gap-5 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2">
<Plus className="size-4 text-benell-brown" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Crear evento</h2>
</header>
<div className="space-y-3">
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Tipo
<select className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text" value={createMode} onChange={(event) => setCreateMode(event.target.value as CreateMode)}>
{canManage ? <option value="meeting">meeting</option> : null}
<option value="personal_event">personal_event</option>
</select>
{!canManage ? <span className="text-xs text-benell-text-soft">Como colaborador, solo puedes crear eventos personales.</span> : null}
</label>
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Título
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
placeholder="Planeación semanal"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Agenda / notas
<textarea
value={agenda}
onChange={(event) => setAgenda(event.target.value)}
className="min-h-[88px] rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
placeholder="Objetivos y puntos clave"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Inicio
<input
type="datetime-local"
value={startAtInput}
onChange={(event) => setStartAtInput(event.target.value)}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Fin
<input
type="datetime-local"
value={endAtInput}
onChange={(event) => setEndAtInput(event.target.value)}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
/>
</label>
</div>
{createMode === "meeting" ? (
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Participantes
<select
multiple
value={selectedParticipantIds}
onChange={(event) => {
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
setSelectedParticipantIds(values);
}}
className="min-h-[110px] rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{employee.name}
</option>
))}
</select>
</label>
) : (
<label className="flex flex-col gap-1 text-sm text-benell-text-soft">
Visibilidad
<select
value={visibility}
onChange={(event) => setVisibility(event.target.value as "personal" | "team")}
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
>
<option value="personal">personal</option>
{canManage ? <option value="team">team</option> : null}
</select>
</label>
)}
<button
type="button"
onClick={() => void handleCreate()}
disabled={isSaving}
className="inline-flex items-center justify-center rounded-lg bg-benell-brown px-4 py-2 text-sm font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-70"
>
{isSaving ? "Guardando..." : "Crear"}
</button>
</div>
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h2 className="text-base font-semibold text-benell-text">Reuniones y confirmaciones</h2>
<p className="mt-1 text-sm text-benell-text-soft">Estado de asistencia y pendientes de confirmación.</p>
<div className="mt-3 space-y-2">
{filteredMeetings.slice(0, 14).map((meeting) => {
const myParticipant = meeting.participants.find((participant) => participant.userId === userId) ?? null;
const pendingCount = meeting.participantCounts.pending;
return (
<article key={meeting.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-benell-text">{meeting.title}</p>
<p className="text-xs text-benell-text-soft">{formatDateTime(meeting.scheduledFor)}</p>
</div>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(meeting.status))}>{meeting.status}</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-benell-text-soft">
<span>Confirmados: {meeting.participantCounts.accepted}</span>
<span>Pendientes: {pendingCount}</span>
<span>Declinados: {meeting.participantCounts.declined}</span>
</div>
{myParticipant ? (
<div className="mt-2 flex gap-2">
<button
type="button"
disabled={isSaving || myParticipant.responseStatus === "accepted"}
onClick={() => void handleRespond(meeting.id, "accepted")}
className="rounded-md border border-benell-green/40 bg-benell-green/10 px-2 py-1 text-xs font-semibold text-benell-green disabled:opacity-60"
>
Accept
</button>
<button
type="button"
disabled={isSaving || myParticipant.responseStatus === "declined"}
onClick={() => void handleRespond(meeting.id, "declined")}
className="rounded-md border border-benell-red/40 bg-benell-red/10 px-2 py-1 text-xs font-semibold text-benell-red disabled:opacity-60"
>
Decline
</button>
</div>
) : null}
</article>
);
})}
{filteredMeetings.length === 0 ? <p className="text-sm text-benell-text-soft">Sin reuniones para el filtro actual.</p> : null}
</div>
</article>
</section>
{isLoading ? <section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 text-sm text-benell-text-soft">Actualizando calendario...</section> : null}
</div>
);
}

View File

@@ -0,0 +1,434 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, CalendarClock, FolderKanban } from "lucide-react";
import { useSession } from "next-auth/react";
import DepartmentKpiTracker from "@/components/kpis/DepartmentKpiTracker";
import { formatCurrency, formatDate, formatDateTime, getStatusPillClass } from "@/lib/marketing/labels";
import { projectLocations } from "@/lib/projects/mock";
import type { Employee, Initiative, Meeting, Task } from "@/lib/projects/types";
import { PROJECT_DATE_RANGE_LABELS } from "@/lib/projects/labels";
import { useProjectsUIStore } from "@/stores/projectsUIStore";
import { cn } from "@/lib/utils";
const NOW = new Date("2026-03-04T12: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 matchesLocationScope(initiative: Initiative, selectedLocationId: "all" | string): boolean {
if (selectedLocationId === "all") {
return true;
}
return initiative.isGlobal || initiative.locationIds.includes(selectedLocationId);
}
export default function ProjectsOverviewPage() {
const { data: session } = useSession();
const { locationId, dateRange, setLocationId, setDateRange } = useProjectsUIStore();
const [employees, setEmployees] = useState<Employee[]>([]);
const [initiatives, setInitiatives] = useState<Initiative[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setIsLoading(true);
try {
const [dashboardResponse, meetingsResponse] = await Promise.all([
fetch("/api/projects/dashboard", { method: "GET", cache: "no-store" }),
fetch("/api/projects/meetings", { method: "GET", cache: "no-store" }),
]);
const dashboardPayload = (await dashboardResponse.json()) as {
error?: string;
employees?: Employee[];
initiatives?: Initiative[];
tasks?: Task[];
};
if (!dashboardResponse.ok) {
throw new Error(dashboardPayload.error ?? "No se pudo cargar el dashboard de Projects.");
}
const meetingsPayload = (await meetingsResponse.json()) as {
error?: string;
meetings?: Meeting[];
};
if (!meetingsResponse.ok) {
throw new Error(meetingsPayload.error ?? "No se pudieron cargar las reuniones de Projects.");
}
if (cancelled) {
return;
}
setEmployees(dashboardPayload.employees ?? []);
setInitiatives(dashboardPayload.initiatives ?? []);
setTasks(dashboardPayload.tasks ?? []);
setMeetings(meetingsPayload.meetings ?? []);
setError(null);
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Projects.");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const employeesById = useMemo(() => Object.fromEntries(employees.map((employee) => [employee.id, employee])), [employees]);
const filteredInitiatives = useMemo(
() =>
initiatives.filter((initiative) => {
const byDate = matchesDateRange(initiative, dateRange);
const byLocation = matchesLocationScope(initiative, locationId);
return byDate && byLocation;
}),
[dateRange, initiatives, locationId]
);
const filteredInitiativeIds = useMemo(() => new Set(filteredInitiatives.map((initiative) => initiative.id)), [filteredInitiatives]);
const filteredTasks = useMemo(() => tasks.filter((task) => filteredInitiativeIds.has(task.initiativeId)), [filteredInitiativeIds, tasks]);
const activeProjects = useMemo(
() => filteredInitiatives.filter((initiative) => initiative.status !== "results" && initiative.status !== "evaluation").length,
[filteredInitiatives]
);
const allMilestones = useMemo(() => filteredInitiatives.flatMap((initiative) => initiative.milestones ?? []), [filteredInitiatives]);
const onTimeMilestonesPct = useMemo(() => {
if (allMilestones.length === 0) {
return 0;
}
const onTimeCount = allMilestones.filter((milestone) => {
if (milestone.status === "completed") {
return true;
}
return new Date(milestone.dueDate).getTime() >= NOW;
}).length;
return Math.round((onTimeCount / allMilestones.length) * 100);
}, [allMilestones]);
const blockedTasksCount = useMemo(() => filteredTasks.filter((task) => task.status === "blocked").length, [filteredTasks]);
const overdueTasksCount = useMemo(
() => filteredTasks.filter((task) => task.status !== "done" && new Date(task.dueDate).getTime() < NOW).length,
[filteredTasks]
);
const actualRevenueTotal = useMemo(
() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.actualRevenue, 0),
[filteredInitiatives]
);
const actualProfitTotal = useMemo(() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.actualProfit, 0), [filteredInitiatives]);
const plannedCostTotal = useMemo(() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.plannedCost, 0), [filteredInitiatives]);
const actualCostTotal = useMemo(() => filteredInitiatives.reduce((acc, initiative) => acc + initiative.actualCost, 0), [filteredInitiatives]);
const costVariancePctTotal = useMemo(() => {
if (plannedCostTotal <= 0) {
return 0;
}
return ((actualCostTotal - plannedCostTotal) / plannedCostTotal) * 100;
}, [actualCostTotal, plannedCostTotal]);
const marginPctTotal = useMemo(() => {
if (actualRevenueTotal <= 0) {
return 0;
}
return (actualProfitTotal / actualRevenueTotal) * 100;
}, [actualProfitTotal, actualRevenueTotal]);
const alertItems = useMemo(() => {
const blocked = filteredTasks
.filter((task) => task.status === "blocked")
.slice(0, 4)
.map((task) => ({
id: `blocked:${task.id}`,
title: `Bloqueo: ${task.title}`,
detail: `Responsable: ${employeesById[task.assigneeId]?.name ?? "Sin asignar"}`,
}));
const overdueMilestones = allMilestones
.filter((milestone) => milestone.status !== "completed" && new Date(milestone.dueDate).getTime() < NOW)
.slice(0, 4)
.map((milestone) => ({
id: `milestone:${milestone.id}`,
title: `Hito vencido: ${milestone.title}`,
detail: `Fecha objetivo: ${formatDate(milestone.dueDate)}`,
}));
const budgetRisk = filteredInitiatives
.filter((initiative) => initiative.costVariancePct >= 10)
.slice(0, 4)
.map((initiative) => ({
id: `budget:${initiative.id}`,
title: `Riesgo de costo: ${initiative.name}`,
detail: `Variación: ${initiative.costVariancePct.toFixed(1)}%`,
}));
return [...blocked, ...overdueMilestones, ...budgetRisk].slice(0, 8);
}, [allMilestones, employeesById, filteredInitiatives, filteredTasks]);
const myTasks = useMemo(() => {
const userId = session?.user?.id;
if (!userId) {
return [];
}
return filteredTasks
.filter((task) => task.assigneeId === userId)
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())
.slice(0, 8);
}, [filteredTasks, session?.user?.id]);
const upcomingMeetings = useMemo(
() =>
meetings
.filter((meeting) => meeting.status === "scheduled" || meeting.status === "requested")
.sort((a, b) => new Date(a.scheduledFor ?? "2999-12-31T00:00:00Z").getTime() - new Date(b.scheduledFor ?? "2999-12-31T00:00:00Z").getTime())
.slice(0, 6),
[meetings]
);
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Projects Overview</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Resumen operativo</h1>
<p className="mt-1 text-sm text-benell-text-soft">Visibilidad de ejecución, alertas y carga operativa.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="flex min-w-[180px] flex-col gap-1 text-sm text-benell-text-soft">
Rango
<select
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
value={dateRange}
onChange={(event) => setDateRange(event.target.value as "7d" | "30d" | "qtd")}
>
<option value="7d">{PROJECT_DATE_RANGE_LABELS["7d"]}</option>
<option value="30d">{PROJECT_DATE_RANGE_LABELS["30d"]}</option>
<option value="qtd">{PROJECT_DATE_RANGE_LABELS.qtd}</option>
</select>
</label>
<label className="flex min-w-[180px] flex-col gap-1 text-sm text-benell-text-soft">
Ubicación
<select
className="rounded-lg border border-benell-stroke bg-white px-3 py-2 text-sm text-benell-text"
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
<option value="all">Todas</option>
{projectLocations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<Link
href="/departments/projects/projects"
className="inline-flex items-center justify-center rounded-lg bg-benell-brown px-4 py-2 text-sm font-semibold text-white transition hover:brightness-95"
>
Nuevo proyecto
</Link>
</div>
</div>
</section>
{error ? (
<section className="rounded-benell border border-benell-red/40 bg-benell-red/10 p-4 text-sm text-benell-red">{error}</section>
) : null}
<DepartmentKpiTracker
department="proyectos"
title="KPIs del área: Projects (OOH / Comunidad)"
description="Gestiona medición, evidencia, score y actualización de KPIs de Uber Eats, Grab & Go, eventos y nuevos proyectos."
/>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
{[
{ label: "Proyectos activos", value: activeProjects.toString() },
{ label: "Hitos en tiempo", value: `${onTimeMilestonesPct}%` },
{ label: "Tareas bloqueadas", value: blockedTasksCount.toString() },
{ label: "Tareas vencidas", value: overdueTasksCount.toString() },
{ label: "Variación de costo", value: `${costVariancePctTotal.toFixed(1)}%` },
{ label: "Margen", value: `${marginPctTotal.toFixed(1)}%` },
].map((card) => (
<article key={card.label} className="rounded-benell border border-benell-stroke bg-benell-surface p-4 shadow-benell">
<p className="text-xs uppercase tracking-wide text-benell-text-soft">{card.label}</p>
<p className="mt-2 text-2xl font-semibold text-benell-text">{card.value}</p>
</article>
))}
</section>
<section className="grid gap-5 xl:grid-cols-2">
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2 text-benell-red">
<AlertTriangle className="size-4" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Alertas prioritarias</h2>
</header>
{alertItems.length === 0 ? (
<p className="text-sm text-benell-text-soft">Sin alertas críticas en el rango actual.</p>
) : (
<ul className="space-y-2">
{alertItems.map((item) => (
<li key={item.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<p className="text-sm font-semibold text-benell-text">{item.title}</p>
<p className="text-xs text-benell-text-soft">{item.detail}</p>
</li>
))}
</ul>
)}
</article>
<article className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2 text-benell-brown">
<CalendarClock className="size-4" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Próximas reuniones</h2>
</header>
{upcomingMeetings.length === 0 ? (
<p className="text-sm text-benell-text-soft">Sin reuniones próximas.</p>
) : (
<ul className="space-y-2">
{upcomingMeetings.map((meeting) => (
<li key={meeting.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-benell-text">{meeting.title}</p>
<p className="text-xs text-benell-text-soft">{formatDateTime(meeting.scheduledFor)}</p>
</div>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(meeting.status))}>
{meeting.status}
</span>
</div>
</li>
))}
</ul>
)}
<div className="mt-4">
<Link href="/departments/projects/meetings" className="text-sm font-semibold text-benell-brown hover:underline">
Ir a calendario semanal
</Link>
</div>
</article>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<header className="mb-3 flex items-center gap-2 text-benell-brown">
<FolderKanban className="size-4" aria-hidden />
<h2 className="text-base font-semibold text-benell-text">Portafolio (vista rápida)</h2>
</header>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-benell-stroke text-sm">
<thead className="bg-benell-surface-muted text-left text-xs uppercase tracking-wide text-benell-text-soft">
<tr>
<th className="px-3 py-2">Proyecto</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Owner</th>
<th className="px-3 py-2">Due date</th>
<th className="px-3 py-2">Variación costo</th>
</tr>
</thead>
<tbody className="divide-y divide-benell-stroke">
{filteredInitiatives.slice(0, 10).map((initiative) => (
<tr key={initiative.id} className="hover:bg-benell-surface-muted/70">
<td className="px-3 py-2">
<Link href="/departments/projects/projects" className="font-semibold text-benell-brown hover:underline">
{initiative.name}
</Link>
</td>
<td className="px-3 py-2">
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(initiative.status))}>
{initiative.status}
</span>
</td>
<td className="px-3 py-2 text-benell-text-soft">{employeesById[initiative.ownerId]?.name ?? "Sin asignar"}</td>
<td className="px-3 py-2 text-benell-text-soft">{formatDate(initiative.dueDate)}</td>
<td className="px-3 py-2 text-benell-text-soft">{initiative.costVariancePct.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<h2 className="text-base font-semibold text-benell-text">Mi carga de trabajo</h2>
<p className="mt-1 text-sm text-benell-text-soft">Asignaciones y compromisos del usuario actual.</p>
{myTasks.length === 0 ? (
<p className="mt-3 text-sm text-benell-text-soft">Sin tareas asignadas en el rango seleccionado.</p>
) : (
<ul className="mt-3 space-y-2">
{myTasks.map((task) => (
<li key={task.id} className="rounded-lg border border-benell-stroke bg-white px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-benell-text">{task.title}</p>
<p className="text-xs text-benell-text-soft">Vence {formatDate(task.dueDate)}</p>
</div>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-semibold", getStatusPillClass(task.status))}>{task.status}</span>
</div>
</li>
))}
</ul>
)}
</section>
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-4 text-sm text-benell-text-soft">
<p>
Totales financieros del rango: costo plan {formatCurrency(plannedCostTotal)}, costo real {formatCurrency(actualCostTotal)}, utilidad real
{" "}
{formatCurrency(actualProfitTotal)}.
</p>
{isLoading ? <p className="mt-2">Actualizando datos...</p> : null}
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Users } from "lucide-react";
import { formatDate } from "@/lib/marketing/labels";
import type { Employee, Initiative, Task } from "@/lib/projects/types";
export default function ProjectsTeamPage() {
const [employees, setEmployees] = useState<Employee[]>([]);
const [initiatives, setInitiatives] = useState<Initiative[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setIsLoading(true);
try {
const response = await fetch("/api/projects/dashboard", {
method: "GET",
cache: "no-store",
});
const payload = (await response.json()) as {
error?: string;
employees?: Employee[];
initiatives?: Initiative[];
tasks?: Task[];
};
if (!response.ok) {
throw new Error(payload.error ?? "No se pudo cargar Team.");
}
if (cancelled) {
return;
}
setEmployees(payload.employees ?? []);
setInitiatives(payload.initiatives ?? []);
setTasks(payload.tasks ?? []);
setError(null);
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "No se pudo cargar Team.");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const initiativeById = useMemo(() => Object.fromEntries(initiatives.map((initiative) => [initiative.id, initiative])), [initiatives]);
const rows = useMemo(() => {
return employees.map((employee) => {
const assignedTasks = tasks.filter((task) => task.assigneeId === employee.id);
const blockedItems = assignedTasks.filter((task) => task.status === "blocked");
const assignedProjects = Array.from(new Set(assignedTasks.map((task) => initiativeById[task.initiativeId]?.name).filter(Boolean))).slice(0, 4);
return {
employee,
assignedTasks,
blockedItems,
assignedProjects,
};
});
}, [employees, initiativeById, tasks]);
return (
<div className="space-y-5">
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="flex items-start gap-3">
<span className="mt-1 inline-flex size-8 items-center justify-center rounded-full bg-benell-brown/10 text-benell-brown">
<Users className="size-4" aria-hidden />
</span>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-benell-brown">Projects Team</p>
<h1 className="mt-1 text-2xl font-semibold text-benell-text">Visibilidad de equipo</h1>
<p className="mt-1 text-sm text-benell-text-soft">Vista de sólo lectura de roles, carga activa y bloqueos.</p>
</div>
</div>
</section>
{error ? <section className="rounded-benell border border-benell-red/40 bg-benell-red/10 p-4 text-sm text-benell-red">{error}</section> : null}
<section className="rounded-benell border border-benell-stroke bg-benell-surface p-5 shadow-benell">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-benell-stroke text-sm">
<thead className="bg-benell-surface-muted text-left text-xs uppercase tracking-wide text-benell-text-soft">
<tr>
<th className="px-3 py-2">Miembro</th>
<th className="px-3 py-2">Rol</th>
<th className="px-3 py-2">Tareas asignadas</th>
<th className="px-3 py-2">Bloqueos</th>
<th className="px-3 py-2">Proyectos</th>
<th className="px-3 py-2">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-benell-stroke">
{rows.map(({ employee, assignedTasks, blockedItems, assignedProjects }) => (
<tr key={employee.id} className="hover:bg-benell-surface-muted/70">
<td className="px-3 py-2 font-semibold text-benell-text">{employee.name}</td>
<td className="px-3 py-2 text-benell-text-soft">{employee.roleTitle}</td>
<td className="px-3 py-2 text-benell-text-soft">{assignedTasks.length}</td>
<td className="px-3 py-2 text-benell-red">{blockedItems.length}</td>
<td className="px-3 py-2 text-benell-text-soft">
{assignedProjects.length > 0 ? assignedProjects.join(", ") : "-"}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-2">
<Link href={`/people/${employee.id}`} className="text-xs font-semibold text-benell-brown hover:underline">
Ver persona
</Link>
<Link href="/departments/projects/projects" className="text-xs font-semibold text-benell-brown hover:underline">
Ver proyectos
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{rows.slice(0, 4).map(({ employee, blockedItems }) => (
<article key={`blocked_${employee.id}`} className="rounded-lg border border-benell-stroke bg-white p-3">
<p className="text-sm font-semibold text-benell-text">Bloqueos de {employee.name}</p>
{blockedItems.length === 0 ? (
<p className="mt-1 text-xs text-benell-text-soft">Sin bloqueos activos.</p>
) : (
<ul className="mt-2 space-y-1 text-xs text-benell-text-soft">
{blockedItems.slice(0, 3).map((task) => (
<li key={task.id}>
{task.title} · vence {formatDate(task.dueDate)}
</li>
))}
</ul>
)}
</article>
))}
</div>
{isLoading ? <p className="mt-4 text-sm text-benell-text-soft">Actualizando equipo...</p> : null}
</section>
</div>
);
}