feat: actualizar carpeta dist probada en XAMPP y modulos de edicion de tiempo y persistencia UI

This commit is contained in:
2026-08-16 19:08:48 -04:00
parent b79a2fa6ad
commit 7e5c955044
20 changed files with 1714 additions and 607 deletions
+1
View File
@@ -2,3 +2,4 @@ VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
VITE_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-proyecto"
VITE_LISTS_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-listas-admin"
VITE_TIME_SHEET_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-registro-horas"
@@ -0,0 +1,25 @@
# Edición de Tiempo + Persistencia de Vista — 2026-08-16
## Edición de registros de tiempo
- Cada registro de **Tiempo** ahora tiene acción **Editar** además de **Eliminar**.
- Editar carga en el formulario: país, tarea, horas/minutos, fecha y nota.
- **Guardar cambios** actualiza el mismo registro en `tablero_cdc_project_time_entries`; no crea un registro nuevo.
- Después de editar se vuelve a ejecutar la sincronización de Registro horas, por lo que el Excel se reconcilia usando el mismo `Tablero Time ID`.
- Se puede cancelar la edición sin modificar el registro.
- La política RLS existente ya permite `UPDATE`, por lo que no requiere SQL adicional.
## Persistencia de la vista
La app guarda el estado de navegación para evitar volver a la vista general si el navegador recarga o descarta una pestaña inactiva.
Se preservan:
- apartado: Todos / Activos / Cerrados / Nuevos / Rechazados;
- búsqueda;
- filtros avanzados;
- página actual por apartado;
- posición vertical de la página;
- apertura de Administración de Listas o Tarifario.
El source utiliza `localStorage` y el `dist` incluye un módulo de compatibilidad para conservar la misma experiencia en la versión compilada/probada.
+22
View File
@@ -0,0 +1,22 @@
# Tablero CDC — Métricas + proyecto multipaís único
## Cambios
1. El monto interno visible en cada tarjeta se aumentó ligeramente para mejorar legibilidad.
2. Los proyectos creados con varios países ahora se guardan como **un solo proyecto**:
- `country`: países separados por coma.
- `country_manager`: CM correspondientes separados por coma.
- El workflow de Sync Proyecto existente hace un único upsert por `Project ID`, por lo que queda una sola fila en `PROYECTOS 2026`.
- Los filtros existentes continúan encontrando el proyecto por cualquiera de sus países porque soportan valores múltiples/`ilike`.
3. Se agregó `supabase_tablero_cdc_dashboard_metrics.sql` para:
- registrar sesiones/usuarios que abren la app;
- registrar creación y cambios de estado de proyectos;
- registrar recepción/aprobación/rechazo de briefs;
- hacer backfill de proyectos/briefs existentes;
- exponer la vista `tablero_cdc_dashboard_metrics` con métricas listas para un dashboard futuro.
4. El tracking de apertura es no bloqueante: si el SQL aún no se ha ejecutado, la app sigue funcionando normalmente.
## Importante
- No se fusionan automáticamente proyectos multipaís históricos que ya existen como varias filas/proyectos, porque cada uno tiene su propio `Project ID` y podría tener tiempo, links o tarifario asociado.
- El cambio aplica a nuevas creaciones multipaís.
+34
View File
@@ -0,0 +1,34 @@
# Registro de horas automático — Tablero CDC
## Objetivo
Evitar que el equipo registre el tiempo dos veces. El usuario registra el tiempo una sola vez desde **Tiempo** en Tablero CDC y la app solicita a n8n que reconcilie ese proyecto con la hoja **Registro horas** del archivo **CD_Latam_Registro Horas_2024**.
## Mapeo
| Registro horas | Origen |
|---|---|
| Fecha | Fecha del registro de Tiempo |
| Mes | Derivado de Fecha |
| Día | Derivado de Fecha |
| Proyecto | Proyecto de Tablero CDC |
| Cliente | Cliente del proyecto |
| Pais / BU | País seleccionado en Tiempo |
| CM | Lista País/BU → Country Manager |
| Trabajado | Usuario que registró el tiempo |
| Tipo de trabajo | Tarea registrada |
| Horas | Minutos / 60 |
| Comentarios | Nota del registro |
| # MES | Derivado de Fecha |
| AÑO | Derivado de Fecha |
Las columnas **Fecha requerimiento, Fecha solicitada, Fecha entregada, Ejecutado / Ganado y Time (L:P)** no se inventan: quedan disponibles para el uso actual del equipo y, si alguien las completa manualmente en una fila sincronizada, el reconciliador las preserva.
## Idempotencia y eliminación
El workflow utiliza dos columnas técnicas de la hoja:
- **T: Tablero Time ID**
- **U: Tablero Project ID**
Así puede volver a ejecutar una sincronización sin duplicar filas y puede eliminar del Excel una fila cuyo registro de Tiempo ya fue eliminado de Supabase.
## Recuperación automática
Al crear o eliminar un registro, la app solicita la sincronización inmediatamente. Además, cada vez que se abre el módulo **Tiempo** de un proyecto, se solicita una reconciliación silenciosa en segundo plano. Esto permite reparar un fallo temporal de Google Sheets sin volver a registrar las horas.
+11
View File
@@ -36,3 +36,14 @@ public.tablero_cdc_project_time_entries
```
La duración se guarda en minutos enteros para evitar redondeos.
## Sincronización automática con Registro horas (2026-08-15)
Cada alta o eliminación en **Tiempo dedicado** solicita una reconciliación del proyecto con el workflow n8n `Tablero CDC - Sync Registro Horas`.
- Fuente del tiempo: `tablero_cdc_project_time_entries` en Supabase.
- Destino: `CD_Latam_Registro Horas_2024` → hoja `Registro horas`.
- El workflow usa `Tablero Time ID` y `Tablero Project ID` en columnas T/U para evitar duplicados y detectar eliminaciones.
- Mapeo automático: Fecha, Mes, Día, Proyecto, Cliente, País/BU, CM, Trabajado, Tipo de trabajo, Horas, Comentarios, # MES y AÑO.
- Columnas L:P se dejan disponibles y se preservan si alguien las completa manualmente.
- Al volver a abrir el modal Tiempo, la app vuelve a solicitar la reconciliación en segundo plano, de modo que un fallo temporal de Google Sheets pueda repararse sin registrar las horas dos veces.
+486
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-486
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="icon" type="image/png" sizes="192x192" href="/tablero-cdc/icon-192.png?v=glm-tab-v12" />
<link rel="shortcut icon" type="image/x-icon" href="/tablero-cdc/favicon.ico?v=glm-tab-v12" />
<link rel="apple-touch-icon" sizes="180x180" href="/tablero-cdc/apple-touch-icon.png?v=glm-tab-v12" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-Deer85MW.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-Npcv_R3U.css">
<script type="module" crossorigin src="/tablero-cdc/assets/index-1r0QKSgX.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-C83ItQ_0.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -109,7 +109,7 @@ export function ProjectCard({
<div className="flex items-center gap-2">
{hasInternalAmount && (
<span
className="text-[11px] font-semibold tabular-nums text-foreground"
className="text-sm font-semibold tabular-nums text-foreground"
title="Monto interno del proyecto"
>
{formatInternalAmount(internalAmount)}
+14 -42
View File
@@ -579,41 +579,11 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
setFormError(null);
setSavingStep("Guardando y sincronizando con Google Sheet…");
if (isMultiCountryCreate) {
const countries = selectedCreateCountries;
const results = [];
for (const [index, country] of countries.entries()) {
setSavingStep(`Guardando país ${index + 1} de ${countries.length}: ${country}`);
const countryProject = {
...safeForm,
bu: country,
cm: getCountryManager(country),
id: crypto.randomUUID(),
createdAt: Date.now() + index,
};
results.push(
await add({
...countryProject,
pricingItems: canManageInternalPricing ? pricingItems : undefined,
}),
const createCountries = selectedCreateCountries;
const createCountryManagers = dedupeOptions(
createCountries.map(getCountryManager).filter(Boolean),
);
}
const failedSyncs = results.filter((result) => result.sheetSyncStatus !== "synced");
if (failedSyncs.length === 0) {
toast.success("Proyectos multipaís creados", {
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El total de la estimación interna se aplicó por país.`,
});
} else {
toast.warning("Proyectos multipaís creados con aviso", {
description: `${failedSyncs.length} de ${countries.length} sincronizaciones con Google Sheet requieren revisión.`,
});
}
} else {
const result =
isEdit && project
? await update(project.id, {
@@ -622,13 +592,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
})
: await add({
...safeForm,
// Un proyecto multipaís se conserva como UN solo proyecto.
// Los países y sus CM viajan en el mismo registro y el workflow
// existente hace upsert de una única fila usando Project ID.
bu: joinCountryValues(createCountries),
cm: createCountryManagers.join(", "),
id: crypto.randomUUID(),
createdAt: Date.now(),
pricingItems: canManageInternalPricing ? pricingItems : undefined,
});
showSaveToast(result);
}
onOpenChange(false);
} catch (error) {
@@ -786,8 +760,8 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
</span>
</div>
<p className="text-xs text-muted-foreground">
Para Walmart Connect WMC puedes seleccionar varios países. La app creará un
proyecto y una fila del Sheet por cada país, como hasta ahora.
Para Walmart Connect WMC puedes seleccionar varios países. Todos quedarán
asociados a un solo proyecto y una sola fila del Sheet.
</p>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -846,8 +820,8 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
summaryLabel="países seleccionados"
/>
<p className="text-xs text-muted-foreground">
Si seleccionas varios países al crear, la app creará un proyecto y una fila del
Sheet por cada país.
Si seleccionas varios países, todos quedarán asociados a un solo proyecto y una sola
fila del Sheet.
</p>
</div>
)}
@@ -890,8 +864,8 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
{isMultiCountryCreate && canManageInternalPricing && (
<div className="md:col-span-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Si seleccionas varios países, el total de la estimación interna se aplicará por cada país. La app
no divide el monto automáticamente.
Si seleccionas varios países, el total de la estimación interna corresponde al proyecto
completo y no se multiplica por país.
</div>
)}
@@ -1072,8 +1046,6 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
? "Guardando…"
: isEdit
? "Guardar cambios"
: isMultiCountryCreate
? "Crear proyectos"
: "Crear proyecto"}
</Button>
</div>
+136 -17
View File
@@ -7,6 +7,7 @@ import {
ListChecks,
Loader2,
MapPin,
Pencil,
Plus,
RefreshCw,
Trash2,
@@ -31,10 +32,12 @@ import {
addProjectTimeEntry,
deleteProjectTimeEntry,
loadProjectTimeEntries,
updateProjectTimeEntry,
type Project,
type ProjectTimeEntry,
} from "@/lib/store";
import { cn } from "@/lib/utils";
import { syncProjectTimeToSheet } from "@/lib/timeSheetSync";
import { toast } from "sonner";
interface ProjectTimeDialogProps {
@@ -143,7 +146,9 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [sheetSyncWarning, setSheetSyncWarning] = useState<string | null>(null);
const [country, setCountry] = useState("");
const [taskName, setTaskName] = useState("");
const [hours, setHours] = useState("");
@@ -193,6 +198,13 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
setError(null);
const rows = await loadProjectTimeEntries(project.id);
setEntries(rows);
// Reconciliación silenciosa: al abrir Tiempo se vuelve a comprobar Excel.
void syncProjectTimeToSheet(project.id)
.then(() => setSheetSyncWarning(null))
.catch((syncError) => {
console.warn("No se pudo reconciliar el registro de horas con Excel:", syncError);
});
} catch (loadError) {
console.error("No se pudo cargar el tiempo del proyecto:", loadError);
setEntries([]);
@@ -217,6 +229,8 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
setMinutes("");
setWorkDate(todayLocal());
setNotes("");
setSheetSyncWarning(null);
setEditingId(null);
setPage(1);
void loadEntries();
}, [loadEntries, open, project?.id, projectCountries]);
@@ -230,6 +244,35 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
Math.max(0, Math.floor(Number(minutes || 0)));
const validDuration = Number.isFinite(durationMinutes) && durationMinutes > 0;
const canSubmit = Boolean(country.trim() && taskName.trim() && workDate && validDuration);
const isEditing = Boolean(editingId);
const resetForm = () => {
setCountry(projectCountries[0] || "");
setTaskName("");
setHours("");
setMinutes("");
setWorkDate(todayLocal());
setNotes("");
setEditingId(null);
};
const handleEdit = (entry: ProjectTimeEntry) => {
const safeMinutes = Math.max(0, Math.round(entry.durationMinutes));
setEditingId(entry.id);
setCountry(entry.country);
setTaskName(entry.taskName);
setHours(String(Math.floor(safeMinutes / 60)));
setMinutes(String(safeMinutes % 60));
setWorkDate(entry.workDate);
setNotes(entry.notes || "");
setError(null);
requestAnimationFrame(() => {
document.querySelector('[data-project-time-form="true"]')?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
};
const handleAdd = async () => {
if (!project?.id || !canSubmit || saving) return;
@@ -237,23 +280,38 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
try {
setSaving(true);
setError(null);
const saved = await addProjectTimeEntry(project.id, {
country,
taskName,
durationMinutes,
workDate,
notes,
});
const payload = { country, taskName, durationMinutes, workDate, notes };
const saved = editingId
? await updateProjectTimeEntry(editingId, payload)
: await addProjectTimeEntry(project.id, payload);
if (editingId) {
setEntries((current) =>
current
.map((item) => (item.id === saved.id ? saved : item))
.sort((a, b) => b.workDate.localeCompare(a.workDate) || b.createdAt - a.createdAt),
);
} else {
setEntries((current) => [saved, ...current]);
setTaskName("");
setHours("");
setMinutes("");
setNotes("");
setPage(1);
toast.success("Tiempo registrado", {
}
resetForm();
toast.success(editingId ? "Registro de tiempo actualizado" : "Tiempo registrado", {
description: `${formatDuration(saved.durationMinutes, true)} · ${saved.country} · ${saved.taskName}`,
});
try {
await syncProjectTimeToSheet(project.id);
setSheetSyncWarning(null);
} catch (syncError) {
console.warn("El tiempo se guardó, pero Excel no pudo sincronizarse todavía:", syncError);
setSheetSyncWarning(
"El tiempo quedó guardado en el Tablero, pero el Excel no pudo actualizarse ahora. Se reintentará automáticamente al volver a abrir Tiempo.",
);
toast.warning("Tiempo guardado; Excel pendiente", {
description: "La app volverá a intentar la sincronización automáticamente.",
});
}
} catch (saveError) {
console.error("No se pudo registrar el tiempo:", saveError);
const message = isMissingTimeTableError(saveError)
@@ -279,7 +337,23 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
setDeletingId(entry.id);
await deleteProjectTimeEntry(entry.id);
setEntries((current) => current.filter((item) => item.id !== entry.id));
if (editingId === entry.id) resetForm();
toast.success("Registro de tiempo eliminado");
if (project?.id) {
try {
await syncProjectTimeToSheet(project.id);
setSheetSyncWarning(null);
} catch (syncError) {
console.warn("El registro se eliminó, pero Excel no pudo sincronizarse todavía:", syncError);
setSheetSyncWarning(
"El registro se eliminó del Tablero, pero el Excel no pudo actualizarse ahora. Se reintentará automáticamente al volver a abrir Tiempo.",
);
toast.warning("Registro eliminado; Excel pendiente", {
description: "La app volverá a intentar la sincronización automáticamente.",
});
}
}
} catch (deleteError) {
console.error("No se pudo eliminar el registro de tiempo:", deleteError);
toast.error("No se pudo eliminar", {
@@ -329,12 +403,14 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
/>
</div>
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<section data-project-time-form="true" className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Agregar tiempo</h3>
<h3 className="text-sm font-semibold">{isEditing ? "Editar tiempo" : "Agregar tiempo"}</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
Cada registro queda asociado a este proyecto y al usuario que lo agrega.
{isEditing
? "Modifica los datos del registro seleccionado y guarda los cambios."
: "Cada registro queda asociado a este proyecto y al usuario que lo agrega."}
</p>
</div>
<Button
@@ -457,6 +533,18 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
<p className="text-xs text-muted-foreground">
Duración a registrar: <span className="font-semibold text-foreground">{formatDuration(durationMinutes, true)}</span>
</p>
<div className="flex flex-wrap items-center justify-end gap-2">
{isEditing && (
<Button
type="button"
variant="outline"
onClick={resetForm}
disabled={saving}
className="rounded-full"
>
Cancelar
</Button>
)}
<Button
type="button"
onClick={() => void handleAdd()}
@@ -465,12 +553,21 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEditing ? (
<Pencil className="h-4 w-4" />
) : (
<Plus className="h-4 w-4" />
)}
{saving ? "Guardando…" : "Agregar registro"}
{saving ? "Guardando…" : isEditing ? "Guardar cambios" : "Agregar registro"}
</Button>
</div>
</div>
{sheetSyncWarning && (
<div className="mt-4 rounded-xl border border-amber-300/60 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{sheetSyncWarning}
</div>
)}
{error && (
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
@@ -533,6 +630,8 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
key={entry.id}
entry={entry}
deleting={deletingId === entry.id}
editing={editingId === entry.id}
onEdit={() => handleEdit(entry)}
onDelete={() => void handleDelete(entry)}
/>
))}
@@ -653,10 +752,14 @@ function BreakdownCard({
function TimeEntryRow({
entry,
deleting,
editing,
onEdit,
onDelete,
}: {
entry: ProjectTimeEntry;
deleting: boolean;
editing: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
return (
@@ -691,6 +794,21 @@ function TimeEntryRow({
</div>
</div>
<div className="flex items-center justify-self-end gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onEdit}
disabled={deleting}
className={cn(
"h-8 w-8 rounded-full text-muted-foreground hover:bg-primary/10 hover:text-primary",
editing && "bg-primary/10 text-primary",
)}
title="Editar registro"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
@@ -698,7 +816,7 @@ function TimeEntryRow({
onClick={onDelete}
disabled={deleting}
className={cn(
"h-8 w-8 justify-self-end rounded-full text-muted-foreground hover:bg-destructive/10 hover:text-destructive",
"h-8 w-8 rounded-full text-muted-foreground hover:bg-destructive/10 hover:text-destructive",
deleting && "text-destructive",
)}
title="Eliminar registro"
@@ -706,5 +824,6 @@ function TimeEntryRow({
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
+113 -4
View File
@@ -79,9 +79,46 @@ function sortRows(rows: EditableRow[]) {
return [...rows].sort((a, b) => a.value.localeCompare(b.value, "es", { sensitivity: "base" }));
}
const PAGE_SIZE = 25;
type PaginationItem = number | "ellipsis-start" | "ellipsis-end";
function buildPaginationItems(currentPage: number, totalPages: number): PaginationItem[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "ellipsis-end", totalPages];
}
if (currentPage >= totalPages - 3) {
return [
1,
"ellipsis-start",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
];
}
return [
1,
"ellipsis-start",
currentPage - 1,
currentPage,
currentPage + 1,
"ellipsis-end",
totalPages,
];
}
export function ListManagerDialog({ open, onOpenChange }: Props) {
const [category, setCategory] = useState<ManagedListCategory>("client");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const [manager, setManager] = useState("");
const [editingOriginal, setEditingOriginal] = useState<string | null>(null);
@@ -117,6 +154,12 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
);
}, [query, rows]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageStart = (currentPage - 1) * PAGE_SIZE;
const pageRows = filteredRows.slice(pageStart, pageStart + PAGE_SIZE);
const paginationItems = buildPaginationItems(currentPage, totalPages);
const resetDraft = () => {
setValue("");
setManager("");
@@ -131,8 +174,17 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
useEffect(() => {
resetDraft();
setQuery("");
setPage(1);
}, [category]);
useEffect(() => {
setPage(1);
}, [query]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const beginEdit = (row: EditableRow) => {
setEditingOriginal(row.value);
setValue(row.value);
@@ -240,8 +292,9 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
Administración de listas
</DialogTitle>
<DialogDescription className="mt-1 max-w-2xl">
Los cambios se escriben primero en la hoja <strong>Listas</strong> y se
sincronizan inmediatamente con Supabase para que aparezcan en toda la app.
Los cambios que realices aquí se guardarán automáticamente en la hoja{` `}
<strong>Listas</strong> y se reflejarán de inmediato en las opciones disponibles
de la aplicación.
</DialogDescription>
</div>
<div className="flex flex-wrap gap-2">
@@ -348,7 +401,7 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Buscar en ${meta.label.toLowerCase()}`}
placeholder={`Buscar en ${meta.label}`}
className="pl-9"
/>
</div>
@@ -375,7 +428,7 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
</div>
) : (
<div className="divide-y divide-border">
{filteredRows.map((row) => (
{pageRows.map((row) => (
<div
key={normalizeOptionKey(`${category}:${row.value}`)}
className="flex items-center justify-between gap-3 px-4 py-3"
@@ -420,6 +473,62 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
</div>
)}
</div>
{filteredRows.length > 0 && (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-xs text-muted-foreground">
Mostrando {pageStart + 1}{Math.min(pageStart + PAGE_SIZE, filteredRows.length)} de{` `}
{filteredRows.length}
</span>
{totalPages > 1 && (
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5"
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={currentPage === 1}
>
Anterior
</Button>
{paginationItems.map((item) =>
typeof item === "number" ? (
<Button
key={item}
type="button"
variant={item === currentPage ? "default" : "outline"}
size="sm"
className="h-8 min-w-8 px-2"
onClick={() => setPage(item)}
aria-current={item === currentPage ? "page" : undefined}
>
{item}
</Button>
) : (
<span
key={item}
className="flex h-8 min-w-6 items-center justify-center text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
),
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5"
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
disabled={currentPage === totalPages}
>
Siguiente
</Button>
</div>
)}
</div>
)}
</section>
</div>
</div>
+4
View File
@@ -10,6 +10,7 @@ import React, {
import type { User as SupabaseUser } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
import { getActiveTableroCdcAccess, type TableroCdcUserAccess } from "@/lib/accessControl";
import { recordTableroCdcOpen } from "@/lib/appMetrics";
function getRedirectTo(): string {
if (typeof window === "undefined") return "/tablero-cdc/";
@@ -103,6 +104,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(normalizeSupabaseUser(supabaseUser));
setAccess(nextAccess);
setError(null);
// Métrica no bloqueante: una apertura por sesión autenticada/autorizada.
void recordTableroCdcOpen();
}, []);
useEffect(() => {
+47
View File
@@ -0,0 +1,47 @@
import { supabase } from "@/lib/supabase";
const SESSION_ID_KEY = "tablero_cdc_metrics_session_id";
const RECORDED_KEY = "tablero_cdc_metrics_open_recorded";
function getOrCreateSessionId() {
if (typeof window === "undefined") return crypto.randomUUID();
const current = window.sessionStorage.getItem(SESSION_ID_KEY);
if (current) return current;
const next = crypto.randomUUID();
window.sessionStorage.setItem(SESSION_ID_KEY, next);
return next;
}
/**
* Registra una apertura válida del Tablero CDC una vez por sesión del navegador.
* Nunca bloquea el acceso a la app si el módulo de métricas todavía no está instalado.
*/
export async function recordTableroCdcOpen() {
if (typeof window !== "undefined" && window.sessionStorage.getItem(RECORDED_KEY) === "1") {
return;
}
const sessionId = getOrCreateSessionId();
const { error } = await supabase.rpc("tablero_cdc_track_app_open", {
p_session_id: sessionId,
});
if (error) {
const message = String(error.message || "").toLowerCase();
const missingFunction =
message.includes("tablero_cdc_track_app_open") ||
message.includes("could not find the function") ||
message.includes("schema cache");
if (!missingFunction) {
console.warn("No se pudo registrar la métrica de apertura del Tablero CDC:", error);
}
return;
}
if (typeof window !== "undefined") {
window.sessionStorage.setItem(RECORDED_KEY, "1");
}
}
+41
View File
@@ -1007,6 +1007,47 @@ export async function addProjectTimeEntry(
return mapDbProjectTimeEntry(data as DbProjectTimeEntry);
}
export async function updateProjectTimeEntry(
id: string,
input: ProjectTimeEntryInput,
): Promise<ProjectTimeEntry> {
const cleanId = String(id || "").trim();
const country = String(input.country || "").trim();
const taskName = String(input.taskName || "").trim();
const durationMinutes = Math.round(Number(input.durationMinutes || 0));
const workDate = String(input.workDate || "").trim();
const notes = String(input.notes || "").trim();
if (!cleanId) throw new Error("No se encontró el registro de tiempo.");
if (!country) throw new Error("Selecciona el país.");
if (!taskName) throw new Error("Escribe la tarea realizada.");
if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {
throw new Error("La duración debe ser mayor que 0 minutos.");
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(workDate)) {
throw new Error("Selecciona una fecha válida.");
}
const { data, error } = await supabase
.from("tablero_cdc_project_time_entries")
.update({
country,
task_name: taskName,
duration_minutes: durationMinutes,
work_date: workDate,
notes,
})
.eq("id", cleanId)
.select(
"id, project_id, country, task_name, duration_minutes, work_date, notes, created_by_name, created_by_email, created_at, updated_at",
)
.single();
if (error) throw error;
return mapDbProjectTimeEntry(data as DbProjectTimeEntry);
}
export async function deleteProjectTimeEntry(id: string): Promise<void> {
const cleanId = String(id || "").trim();
+90
View File
@@ -0,0 +1,90 @@
import { supabase } from "@/lib/supabase";
const DEFAULT_TIME_SHEET_WEBHOOK_URL =
"https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-registro-horas";
export type TimeSheetSyncResult = {
ok: boolean;
message?: string;
projectId?: string;
syncedEntries?: number;
clearedEntries?: number;
};
function getWebhookUrl() {
return (
String(import.meta.env.VITE_TIME_SHEET_WEBHOOK_URL || "").trim() ||
DEFAULT_TIME_SHEET_WEBHOOK_URL
);
}
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 20_000) {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
window.clearTimeout(timeout);
}
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
export async function syncProjectTimeToSheet(
projectId: string,
): Promise<TimeSheetSyncResult> {
const cleanProjectId = String(projectId || "").trim();
if (!cleanProjectId) throw new Error("No se encontró el proyecto para sincronizar las horas.");
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) throw sessionError;
if (!session?.access_token) {
throw new Error("No se encontró una sesión activa para sincronizar el registro de horas.");
}
let lastError: Error | null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const response = await fetchWithTimeout(getWebhookUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "sync_project",
project_id: cleanProjectId,
access_token: session.access_token,
source: "tablero-cdc-time",
}),
});
const text = await response.text();
let payload: TimeSheetSyncResult | null = null;
if (text) {
try {
payload = JSON.parse(text) as TimeSheetSyncResult;
} catch {
payload = null;
}
}
if (!response.ok || payload?.ok === false) {
throw new Error(payload?.message || text || `Error ${response.status} al sincronizar Excel.`);
}
return payload || { ok: true, projectId: cleanProjectId };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error || "Error de sincronización"));
if (attempt < 3) await wait(attempt * 1_200);
}
}
throw lastError || new Error("No se pudo sincronizar el registro de horas con Excel.");
}
+127 -14
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import {
ChevronDown,
ChevronLeft,
@@ -69,6 +69,73 @@ const EMPTY_ADVANCED_FILTERS: AdvancedFilters = {
cm: "",
};
const DEFAULT_PAGE_BY_FILTER: Record<BoardView, number> = {
nuevos: 1,
todos: 1,
abiertos: 1,
cerrados: 1,
no_aprobados: 1,
};
const BOARD_UI_STATE_KEY = "tablero-cdc:ui-state:v1";
type PersistedBoardUiState = {
query: string;
filter: BoardView;
advancedFilters: AdvancedFilters;
pageByFilter: Record<BoardView, number>;
tariffManagerOpen: boolean;
listManagerOpen: boolean;
scrollY: number;
};
function loadBoardUiState(): PersistedBoardUiState {
const fallback: PersistedBoardUiState = {
query: "",
filter: "todos",
advancedFilters: EMPTY_ADVANCED_FILTERS,
pageByFilter: DEFAULT_PAGE_BY_FILTER,
tariffManagerOpen: false,
listManagerOpen: false,
scrollY: 0,
};
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(BOARD_UI_STATE_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as Partial<PersistedBoardUiState>;
const validFilters: BoardView[] = ["nuevos", "todos", "abiertos", "cerrados", "no_aprobados"];
const nextFilter = validFilters.includes(parsed.filter as BoardView)
? (parsed.filter as BoardView)
: fallback.filter;
return {
query: typeof parsed.query === "string" ? parsed.query : fallback.query,
filter: nextFilter,
advancedFilters: {
country: typeof parsed.advancedFilters?.country === "string" ? parsed.advancedFilters.country : "",
brand: typeof parsed.advancedFilters?.brand === "string" ? parsed.advancedFilters.brand : "",
client: typeof parsed.advancedFilters?.client === "string" ? parsed.advancedFilters.client : "",
cm: typeof parsed.advancedFilters?.cm === "string" ? parsed.advancedFilters.cm : "",
},
pageByFilter: Object.fromEntries(
Object.entries({ ...DEFAULT_PAGE_BY_FILTER, ...(parsed.pageByFilter || {}) }).map(([key, value]) => [
key,
Math.max(1, Math.floor(Number(value) || 1)),
]),
) as Record<BoardView, number>,
tariffManagerOpen: parsed.tariffManagerOpen === true,
listManagerOpen: parsed.listManagerOpen === true,
scrollY: Number.isFinite(Number(parsed.scrollY)) ? Math.max(0, Number(parsed.scrollY)) : 0,
};
} catch (error) {
console.warn("No se pudo restaurar la vista anterior del Tablero CDC:", error);
return fallback;
}
}
function initials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
const parts = name.trim().split(/\s+/);
@@ -80,21 +147,18 @@ function initials(name: string | null | undefined, email: string | null | undefi
}
export default function BoardPage() {
const [initialUiState] = useState(loadBoardUiState);
const hasMountedFilterReset = useRef(false);
const hasRestoredScroll = useRef(initialUiState.scrollY <= 0);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Project | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<BoardView>("todos");
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>({
nuevos: 1,
todos: 1,
abiertos: 1,
cerrados: 1,
no_aprobados: 1,
});
const [query, setQuery] = useState(initialUiState.query);
const [filter, setFilter] = useState<BoardView>(initialUiState.filter);
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(initialUiState.advancedFilters);
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>(initialUiState.pageByFilter);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [tariffManagerOpen, setTariffManagerOpen] = useState(false);
const [listManagerOpen, setListManagerOpen] = useState(false);
const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen);
const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen);
const [timeProject, setTimeProject] = useState<Project | null>(null);
const [reviewingBriefId, setReviewingBriefId] = useState<string | null>(null);
@@ -185,16 +249,65 @@ export default function BoardPage() {
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, currentTotalItems);
useEffect(() => {
if (!hasMountedFilterReset.current) {
hasMountedFilterReset.current = true;
return;
}
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
}, [
query,
filter,
advancedFilters.country,
advancedFilters.brand,
advancedFilters.client,
advancedFilters.cm,
]);
useEffect(() => {
const saveUiState = () => {
try {
const nextState: PersistedBoardUiState = {
query,
filter,
advancedFilters,
pageByFilter,
tariffManagerOpen,
listManagerOpen,
scrollY: hasRestoredScroll.current ? window.scrollY : initialUiState.scrollY,
};
window.localStorage.setItem(BOARD_UI_STATE_KEY, JSON.stringify(nextState));
} catch (error) {
console.warn("No se pudo guardar la vista del Tablero CDC:", error);
}
};
saveUiState();
const handlePageHide = () => saveUiState();
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") saveUiState();
};
window.addEventListener("pagehide", handlePageHide);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("pagehide", handlePageHide);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, initialUiState.scrollY]);
useEffect(() => {
const savedScrollY = initialUiState.scrollY;
const currentViewLoading = isBriefReviewView ? briefInbox.loading : loading;
if (savedScrollY <= 0 || currentViewLoading || hasRestoredScroll.current) return;
const timer = window.setTimeout(() => {
window.scrollTo({ top: savedScrollY, behavior: "auto" });
hasRestoredScroll.current = true;
}, 120);
return () => window.clearTimeout(timer);
}, [initialUiState.scrollY, isBriefReviewView, briefInbox.loading, loading]);
useEffect(() => {
if (currentPage > totalPages) {
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
+519
View File
@@ -0,0 +1,519 @@
-- ============================================================================
-- TABLERO CDC — MÉTRICAS PARA DASHBOARD FUTURO
-- Fecha: 2026-08-16
--
-- Objetivo:
-- 1) Registrar usuarios/sesiones que realmente abren Tablero CDC.
-- 2) Registrar eventos relevantes de proyectos sin depender del frontend.
-- 3) Exponer un resumen listo para un futuro dashboard.
--
-- Es idempotente: puede ejecutarse nuevamente sin duplicar el backfill.
-- ============================================================================
create extension if not exists pgcrypto;
create table if not exists public.tablero_cdc_app_metrics_events (
id uuid primary key default gen_random_uuid(),
event_type text not null,
event_key text not null unique,
actor_user_id uuid references auth.users(id) on delete set null,
actor_email text,
actor_name text,
project_id uuid references public.tablero_cdc_projects(id) on delete set null,
session_id text,
metadata jsonb not null default '{}'::jsonb,
occurred_at timestamptz not null default now(),
created_at timestamptz not null default now()
);
create index if not exists tablero_cdc_metrics_event_type_idx
on public.tablero_cdc_app_metrics_events (event_type, occurred_at desc);
create index if not exists tablero_cdc_metrics_actor_idx
on public.tablero_cdc_app_metrics_events (actor_user_id, occurred_at desc);
create index if not exists tablero_cdc_metrics_project_idx
on public.tablero_cdc_app_metrics_events (project_id, occurred_at desc)
where project_id is not null;
alter table public.tablero_cdc_app_metrics_events enable row level security;
-- El dashboard futuro podrá leer las métricas únicamente con usuarios activos
-- que ya tienen permiso para ver/controlar el resumen económico del Tablero.
drop policy if exists "tablero_cdc_metrics_select_authorized" on public.tablero_cdc_app_metrics_events;
create policy "tablero_cdc_metrics_select_authorized"
on public.tablero_cdc_app_metrics_events
for select
to authenticated
using (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(u.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
and coalesce(u.can_control_pricing_summary, false) = true
)
);
-- No damos INSERT directo al cliente. Las aperturas se registran vía RPC y
-- los eventos de proyecto vía trigger, evitando que el navegador falsifique métricas.
revoke insert, update, delete on public.tablero_cdc_app_metrics_events from authenticated;
grant select on public.tablero_cdc_app_metrics_events to authenticated;
-- --------------------------------------------------------------------------
-- Helper de identidad para los eventos automáticos.
-- --------------------------------------------------------------------------
create or replace function public.tablero_cdc_metric_actor(
p_user_id uuid,
out actor_email text,
out actor_name text
)
language plpgsql
security definer
set search_path = public, auth
as $$
begin
actor_email := null;
actor_name := null;
if p_user_id is null then
return;
end if;
select
u.email,
coalesce(
nullif(u.raw_user_meta_data ->> 'full_name', ''),
nullif(u.raw_user_meta_data ->> 'name', ''),
u.email
)
into actor_email, actor_name
from auth.users u
where u.id = p_user_id
limit 1;
end;
$$;
revoke all on function public.tablero_cdc_metric_actor(uuid) from public;
-- --------------------------------------------------------------------------
-- RPC: una apertura por sesión autenticada/autorizada.
-- --------------------------------------------------------------------------
create or replace function public.tablero_cdc_track_app_open(p_session_id text)
returns void
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_uid uuid := auth.uid();
v_email text;
v_name text;
v_session text := nullif(trim(coalesce(p_session_id, '')), '');
begin
if v_uid is null then
raise exception 'Usuario no autenticado';
end if;
if v_session is null or length(v_session) > 200 then
raise exception 'session_id inválido';
end if;
select actor_email, actor_name
into v_email, v_name
from public.tablero_cdc_metric_actor(v_uid);
if not exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(u.email) = lower(coalesce(v_email, ''))
and u.is_active = true
) then
raise exception 'Usuario no autorizado para Tablero CDC';
end if;
insert into public.tablero_cdc_app_metrics_events (
event_type,
event_key,
actor_user_id,
actor_email,
actor_name,
session_id,
metadata
) values (
'app_opened',
'app_opened:' || v_uid::text || ':' || v_session,
v_uid,
v_email,
v_name,
v_session,
jsonb_build_object('app', 'tablero_cdc')
)
on conflict (event_key) do nothing;
end;
$$;
revoke all on function public.tablero_cdc_track_app_open(text) from public;
grant execute on function public.tablero_cdc_track_app_open(text) to authenticated;
-- --------------------------------------------------------------------------
-- Trigger de proyectos: creación + cambios de estado relevantes.
-- --------------------------------------------------------------------------
create or replace function public.tablero_cdc_capture_project_metric()
returns trigger
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_actor uuid;
v_email text;
v_name text;
v_old_status text;
v_new_status text;
v_event text;
begin
v_actor := coalesce(new.updated_by, new.created_by, auth.uid());
select actor_email, actor_name
into v_email, v_name
from public.tablero_cdc_metric_actor(v_actor);
if tg_op = 'INSERT' then
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata, occurred_at
) values (
'project_created',
'project_created:' || new.id::text,
v_actor,
v_email,
v_name,
new.id,
jsonb_build_object(
'title', coalesce(new.title, ''),
'client', coalesce(new.client, ''),
'brand', coalesce(new.brand, ''),
'country', coalesce(new.country, '')
),
coalesce(new.created_at, now())
)
on conflict (event_key) do nothing;
return new;
end if;
v_old_status := lower(trim(coalesce(old.status, '')));
v_new_status := lower(trim(coalesce(new.status, '')));
if v_old_status is not distinct from v_new_status then
return new;
end if;
v_event := case
when v_new_status = 'aprobado' then 'project_approved'
when v_new_status = 'no aprobado' then 'project_rejected'
when v_new_status in ('', 'activo') then 'project_reopened'
else 'project_closed'
end;
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata
) values (
v_event,
v_event || ':' || new.id::text || ':' || gen_random_uuid()::text,
v_actor,
v_email,
v_name,
new.id,
jsonb_build_object(
'title', coalesce(new.title, ''),
'previous_status', coalesce(old.status, ''),
'new_status', coalesce(new.status, '')
)
);
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_project_metrics on public.tablero_cdc_projects;
create trigger trg_tablero_cdc_project_metrics
after insert or update of status
on public.tablero_cdc_projects
for each row
execute function public.tablero_cdc_capture_project_metric();
-- --------------------------------------------------------------------------
-- Trigger de tiempo: permite medir uso real y horas registradas históricamente.
-- --------------------------------------------------------------------------
create or replace function public.tablero_cdc_capture_time_metric()
returns trigger
language plpgsql
security definer
set search_path = public, auth
as $$
begin
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata, occurred_at
) values (
'time_logged',
'time_logged:' || new.id::text,
new.created_by,
new.created_by_email,
coalesce(nullif(new.created_by_name, ''), new.created_by_email),
new.project_id,
jsonb_build_object(
'country', coalesce(new.country, ''),
'task_name', coalesce(new.task_name, ''),
'duration_minutes', coalesce(new.duration_minutes, 0),
'work_date', new.work_date
),
coalesce(new.created_at, now())
)
on conflict (event_key) do nothing;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_time_metrics on public.tablero_cdc_project_time_entries;
create trigger trg_tablero_cdc_time_metrics
after insert
on public.tablero_cdc_project_time_entries
for each row
execute function public.tablero_cdc_capture_time_metric();
-- --------------------------------------------------------------------------
-- Métricas de Briefs (siempre útiles para este Tablero): recibidos/aprobados/
-- rechazados. La tabla ya forma parte del módulo CDC Brief de esta versión.
-- --------------------------------------------------------------------------
create or replace function public.tablero_cdc_capture_brief_metric()
returns trigger
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_actor uuid;
v_email text;
v_name text;
v_event text;
begin
if tg_op = 'INSERT' then
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name, metadata, occurred_at
) values (
'brief_received',
'brief_received:' || new.id::text,
null,
null,
null,
jsonb_build_object('brief_id', coalesce(new.brief_id, ''), 'title', coalesce(new.title, '')),
coalesce(new.created_at, now())
)
on conflict (event_key) do nothing;
return new;
end if;
if old.review_status is not distinct from new.review_status then
return new;
end if;
v_event := case
when new.review_status = 'approved' then 'brief_approved'
when new.review_status = 'rejected' then 'brief_rejected'
else null
end;
if v_event is null then
return new;
end if;
v_actor := coalesce(new.reviewed_by, auth.uid());
select actor_email, actor_name
into v_email, v_name
from public.tablero_cdc_metric_actor(v_actor);
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata
) values (
v_event,
v_event || ':' || new.id::text || ':' || gen_random_uuid()::text,
v_actor,
coalesce(new.reviewed_by_email, v_email),
coalesce(new.reviewed_by_name, v_name),
new.approved_project_id,
jsonb_build_object(
'brief_id', coalesce(new.brief_id, ''),
'title', coalesce(new.title, ''),
'review_status', new.review_status
)
);
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_brief_metrics on public.tablero_cdc_brief_inbox;
create trigger trg_tablero_cdc_brief_metrics
after insert or update of review_status
on public.tablero_cdc_brief_inbox
for each row
execute function public.tablero_cdc_capture_brief_metric();
-- --------------------------------------------------------------------------
-- Backfill seguro: proyectos y briefs existentes para que el futuro dashboard
-- no empiece desde cero. No duplica si se ejecuta otra vez.
-- --------------------------------------------------------------------------
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata, occurred_at
)
select
'project_created',
'project_created:' || p.id::text,
p.created_by,
au.email,
coalesce(nullif(au.raw_user_meta_data ->> 'full_name', ''), nullif(au.raw_user_meta_data ->> 'name', ''), au.email),
p.id,
jsonb_build_object(
'title', coalesce(p.title, ''),
'client', coalesce(p.client, ''),
'brand', coalesce(p.brand, ''),
'country', coalesce(p.country, ''),
'backfill', true
),
coalesce(p.created_at, now())
from public.tablero_cdc_projects p
left join auth.users au on au.id = p.created_by
on conflict (event_key) do nothing;
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata, occurred_at
)
select
case when lower(trim(p.status)) = 'aprobado' then 'project_approved' else 'project_rejected' end,
case when lower(trim(p.status)) = 'aprobado'
then 'project_approved:backfill:' || p.id::text
else 'project_rejected:backfill:' || p.id::text end,
p.updated_by,
au.email,
coalesce(nullif(au.raw_user_meta_data ->> 'full_name', ''), nullif(au.raw_user_meta_data ->> 'name', ''), au.email),
p.id,
jsonb_build_object('status', p.status, 'backfill', true),
now()
from public.tablero_cdc_projects p
left join auth.users au on au.id = p.updated_by
where lower(trim(coalesce(p.status, ''))) in ('aprobado', 'no aprobado')
on conflict (event_key) do nothing;
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata, occurred_at
)
select
'time_logged',
'time_logged:' || t.id::text,
t.created_by,
t.created_by_email,
coalesce(nullif(t.created_by_name, ''), t.created_by_email),
t.project_id,
jsonb_build_object(
'country', coalesce(t.country, ''),
'task_name', coalesce(t.task_name, ''),
'duration_minutes', coalesce(t.duration_minutes, 0),
'work_date', t.work_date,
'backfill', true
),
coalesce(t.created_at, now())
from public.tablero_cdc_project_time_entries t
on conflict (event_key) do nothing;
insert into public.tablero_cdc_app_metrics_events (
event_type, event_key, actor_user_id, actor_email, actor_name,
project_id, metadata, occurred_at
)
select
'brief_received',
'brief_received:' || b.id::text,
null,
null,
null,
b.approved_project_id,
jsonb_build_object('brief_id', coalesce(b.brief_id, ''), 'title', coalesce(b.title, ''), 'backfill', true),
coalesce(b.created_at, now())
from public.tablero_cdc_brief_inbox b
on conflict (event_key) do nothing;
-- --------------------------------------------------------------------------
-- Vista resumen para el dashboard futuro.
-- Los conteos de estado salen del estado ACTUAL de proyectos, evitando inflar
-- métricas cuando un proyecto cambia de estado varias veces.
-- --------------------------------------------------------------------------
create or replace view public.tablero_cdc_dashboard_metrics as
select
(select count(distinct actor_user_id)
from public.tablero_cdc_app_metrics_events
where actor_user_id is not null) as usuarios_que_han_usado_app,
(select count(*)
from public.tablero_cdc_app_metrics_events
where event_type = 'app_opened') as sesiones_registradas,
(select count(*)
from public.tablero_cdc_app_metrics_events
where event_type = 'project_created') as proyectos_creados_historico,
(select count(*) from public.tablero_cdc_projects) as proyectos_actuales,
(select count(*)
from public.tablero_cdc_projects
where lower(trim(coalesce(status, ''))) in ('', 'activo')) as proyectos_activos_actuales,
(select count(*)
from public.tablero_cdc_projects
where lower(trim(coalesce(status, ''))) = 'aprobado') as proyectos_aprobados_actuales,
(select count(*)
from public.tablero_cdc_projects
where lower(trim(coalesce(status, ''))) = 'no aprobado') as proyectos_rechazados_actuales,
(select count(*)
from public.tablero_cdc_app_metrics_events
where event_type = 'project_approved') as aprobaciones_registradas,
(select count(*)
from public.tablero_cdc_app_metrics_events
where event_type = 'project_rejected') as rechazos_registrados,
(select count(*)
from public.tablero_cdc_project_time_entries) as registros_de_tiempo,
(select round(coalesce(sum(duration_minutes), 0)::numeric / 60.0, 2)
from public.tablero_cdc_project_time_entries) as horas_registradas,
(select count(*) from public.tablero_cdc_brief_inbox) as briefs_recibidos,
(select count(*)
from public.tablero_cdc_brief_inbox
where review_status = 'approved') as briefs_aprobados,
(select count(*)
from public.tablero_cdc_brief_inbox
where review_status = 'rejected') as briefs_rechazados,
now() as consultado_en;
grant select on public.tablero_cdc_dashboard_metrics to authenticated;
comment on table public.tablero_cdc_app_metrics_events is
'Eventos de uso y negocio de Tablero CDC para dashboard futuro de aplicaciones GLM.';
comment on view public.tablero_cdc_dashboard_metrics is
'Resumen actual de usuarios, proyectos, tiempos y briefs de Tablero CDC.';