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