feat: actualizar carpeta dist probada en XAMPP y modulos de edicion de tiempo y persistencia UI
This commit is contained in:
@@ -2,3 +2,4 @@ VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
|
|||||||
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
|
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
|
||||||
VITE_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-proyecto"
|
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_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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -36,3 +36,14 @@ public.tablero_cdc_project_time_entries
|
|||||||
```
|
```
|
||||||
|
|
||||||
La duración se guarda en minutos enteros para evitar redondeos.
|
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.
|
||||||
|
|||||||
Vendored
+486
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-486
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
<link rel="icon" type="image/png" sizes="192x192" href="/tablero-cdc/icon-192.png?v=glm-tab-v12" />
|
<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="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" />
|
<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>
|
<script type="module" crossorigin src="/tablero-cdc/assets/index-1r0QKSgX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-Npcv_R3U.css">
|
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-C83ItQ_0.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ProjectCard({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasInternalAmount && (
|
{hasInternalAmount && (
|
||||||
<span
|
<span
|
||||||
className="text-[11px] font-semibold tabular-nums text-foreground"
|
className="text-sm font-semibold tabular-nums text-foreground"
|
||||||
title="Monto interno del proyecto"
|
title="Monto interno del proyecto"
|
||||||
>
|
>
|
||||||
{formatInternalAmount(internalAmount)}
|
{formatInternalAmount(internalAmount)}
|
||||||
|
|||||||
@@ -579,41 +579,11 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
setFormError(null);
|
setFormError(null);
|
||||||
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
||||||
|
|
||||||
if (isMultiCountryCreate) {
|
const createCountries = selectedCreateCountries;
|
||||||
const countries = selectedCreateCountries;
|
const createCountryManagers = dedupeOptions(
|
||||||
const results = [];
|
createCountries.map(getCountryManager).filter(Boolean),
|
||||||
|
|
||||||
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 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 =
|
const result =
|
||||||
isEdit && project
|
isEdit && project
|
||||||
? await update(project.id, {
|
? await update(project.id, {
|
||||||
@@ -622,13 +592,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
})
|
})
|
||||||
: await add({
|
: await add({
|
||||||
...safeForm,
|
...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(),
|
id: crypto.randomUUID(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
showSaveToast(result);
|
showSaveToast(result);
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -786,8 +760,8 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Para Walmart Connect WMC puedes seleccionar varios países. La app creará un
|
Para Walmart Connect WMC puedes seleccionar varios países. Todos quedarán
|
||||||
proyecto y una fila del Sheet por cada país, como hasta ahora.
|
asociados a un solo proyecto y una sola fila del Sheet.
|
||||||
</p>
|
</p>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<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"
|
summaryLabel="países seleccionados"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Si seleccionas varios países al crear, la app creará un proyecto y una fila del
|
Si seleccionas varios países, todos quedarán asociados a un solo proyecto y una sola
|
||||||
Sheet por cada país.
|
fila del Sheet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -890,8 +864,8 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
|
|
||||||
{isMultiCountryCreate && canManageInternalPricing && (
|
{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">
|
<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
|
Si seleccionas varios países, el total de la estimación interna corresponde al proyecto
|
||||||
no divide el monto automáticamente.
|
completo y no se multiplica por país.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1072,8 +1046,6 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
? "Guardando…"
|
? "Guardando…"
|
||||||
: isEdit
|
: isEdit
|
||||||
? "Guardar cambios"
|
? "Guardar cambios"
|
||||||
: isMultiCountryCreate
|
|
||||||
? "Crear proyectos"
|
|
||||||
: "Crear proyecto"}
|
: "Crear proyecto"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -31,10 +32,12 @@ import {
|
|||||||
addProjectTimeEntry,
|
addProjectTimeEntry,
|
||||||
deleteProjectTimeEntry,
|
deleteProjectTimeEntry,
|
||||||
loadProjectTimeEntries,
|
loadProjectTimeEntries,
|
||||||
|
updateProjectTimeEntry,
|
||||||
type Project,
|
type Project,
|
||||||
type ProjectTimeEntry,
|
type ProjectTimeEntry,
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { syncProjectTimeToSheet } from "@/lib/timeSheetSync";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ProjectTimeDialogProps {
|
interface ProjectTimeDialogProps {
|
||||||
@@ -143,7 +146,9 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sheetSyncWarning, setSheetSyncWarning] = useState<string | null>(null);
|
||||||
const [country, setCountry] = useState("");
|
const [country, setCountry] = useState("");
|
||||||
const [taskName, setTaskName] = useState("");
|
const [taskName, setTaskName] = useState("");
|
||||||
const [hours, setHours] = useState("");
|
const [hours, setHours] = useState("");
|
||||||
@@ -193,6 +198,13 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
setError(null);
|
setError(null);
|
||||||
const rows = await loadProjectTimeEntries(project.id);
|
const rows = await loadProjectTimeEntries(project.id);
|
||||||
setEntries(rows);
|
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) {
|
} catch (loadError) {
|
||||||
console.error("No se pudo cargar el tiempo del proyecto:", loadError);
|
console.error("No se pudo cargar el tiempo del proyecto:", loadError);
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
@@ -217,6 +229,8 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
setMinutes("");
|
setMinutes("");
|
||||||
setWorkDate(todayLocal());
|
setWorkDate(todayLocal());
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setSheetSyncWarning(null);
|
||||||
|
setEditingId(null);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
void loadEntries();
|
void loadEntries();
|
||||||
}, [loadEntries, open, project?.id, projectCountries]);
|
}, [loadEntries, open, project?.id, projectCountries]);
|
||||||
@@ -230,6 +244,35 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
Math.max(0, Math.floor(Number(minutes || 0)));
|
Math.max(0, Math.floor(Number(minutes || 0)));
|
||||||
const validDuration = Number.isFinite(durationMinutes) && durationMinutes > 0;
|
const validDuration = Number.isFinite(durationMinutes) && durationMinutes > 0;
|
||||||
const canSubmit = Boolean(country.trim() && taskName.trim() && workDate && validDuration);
|
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 () => {
|
const handleAdd = async () => {
|
||||||
if (!project?.id || !canSubmit || saving) return;
|
if (!project?.id || !canSubmit || saving) return;
|
||||||
@@ -237,23 +280,38 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const saved = await addProjectTimeEntry(project.id, {
|
const payload = { country, taskName, durationMinutes, workDate, notes };
|
||||||
country,
|
const saved = editingId
|
||||||
taskName,
|
? await updateProjectTimeEntry(editingId, payload)
|
||||||
durationMinutes,
|
: await addProjectTimeEntry(project.id, payload);
|
||||||
workDate,
|
|
||||||
notes,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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]);
|
setEntries((current) => [saved, ...current]);
|
||||||
setTaskName("");
|
|
||||||
setHours("");
|
|
||||||
setMinutes("");
|
|
||||||
setNotes("");
|
|
||||||
setPage(1);
|
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}`,
|
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) {
|
} catch (saveError) {
|
||||||
console.error("No se pudo registrar el tiempo:", saveError);
|
console.error("No se pudo registrar el tiempo:", saveError);
|
||||||
const message = isMissingTimeTableError(saveError)
|
const message = isMissingTimeTableError(saveError)
|
||||||
@@ -279,7 +337,23 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
setDeletingId(entry.id);
|
setDeletingId(entry.id);
|
||||||
await deleteProjectTimeEntry(entry.id);
|
await deleteProjectTimeEntry(entry.id);
|
||||||
setEntries((current) => current.filter((item) => item.id !== entry.id));
|
setEntries((current) => current.filter((item) => item.id !== entry.id));
|
||||||
|
if (editingId === entry.id) resetForm();
|
||||||
toast.success("Registro de tiempo eliminado");
|
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) {
|
} catch (deleteError) {
|
||||||
console.error("No se pudo eliminar el registro de tiempo:", deleteError);
|
console.error("No se pudo eliminar el registro de tiempo:", deleteError);
|
||||||
toast.error("No se pudo eliminar", {
|
toast.error("No se pudo eliminar", {
|
||||||
@@ -329,12 +403,14 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -457,6 +533,18 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Duración a registrar: <span className="font-semibold text-foreground">{formatDuration(durationMinutes, true)}</span>
|
Duración a registrar: <span className="font-semibold text-foreground">{formatDuration(durationMinutes, true)}</span>
|
||||||
</p>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleAdd()}
|
onClick={() => void handleAdd()}
|
||||||
@@ -465,12 +553,21 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
|||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : isEditing ? (
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{saving ? "Guardando…" : "Agregar registro"}
|
{saving ? "Guardando…" : isEditing ? "Guardar cambios" : "Agregar registro"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
<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}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
deleting={deletingId === entry.id}
|
deleting={deletingId === entry.id}
|
||||||
|
editing={editingId === entry.id}
|
||||||
|
onEdit={() => handleEdit(entry)}
|
||||||
onDelete={() => void handleDelete(entry)}
|
onDelete={() => void handleDelete(entry)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -653,10 +752,14 @@ function BreakdownCard({
|
|||||||
function TimeEntryRow({
|
function TimeEntryRow({
|
||||||
entry,
|
entry,
|
||||||
deleting,
|
deleting,
|
||||||
|
editing,
|
||||||
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
entry: ProjectTimeEntry;
|
entry: ProjectTimeEntry;
|
||||||
deleting: boolean;
|
deleting: boolean;
|
||||||
|
editing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -691,6 +794,21 @@ function TimeEntryRow({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -698,7 +816,7 @@ function TimeEntryRow({
|
|||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className={cn(
|
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",
|
deleting && "text-destructive",
|
||||||
)}
|
)}
|
||||||
title="Eliminar registro"
|
title="Eliminar registro"
|
||||||
@@ -706,5 +824,6 @@ function TimeEntryRow({
|
|||||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,9 +79,46 @@ function sortRows(rows: EditableRow[]) {
|
|||||||
return [...rows].sort((a, b) => a.value.localeCompare(b.value, "es", { sensitivity: "base" }));
|
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) {
|
export function ListManagerDialog({ open, onOpenChange }: Props) {
|
||||||
const [category, setCategory] = useState<ManagedListCategory>("client");
|
const [category, setCategory] = useState<ManagedListCategory>("client");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [manager, setManager] = useState("");
|
const [manager, setManager] = useState("");
|
||||||
const [editingOriginal, setEditingOriginal] = useState<string | null>(null);
|
const [editingOriginal, setEditingOriginal] = useState<string | null>(null);
|
||||||
@@ -117,6 +154,12 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
|
|||||||
);
|
);
|
||||||
}, [query, rows]);
|
}, [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 = () => {
|
const resetDraft = () => {
|
||||||
setValue("");
|
setValue("");
|
||||||
setManager("");
|
setManager("");
|
||||||
@@ -131,8 +174,17 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetDraft();
|
resetDraft();
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setPage(1);
|
||||||
}, [category]);
|
}, [category]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
|
||||||
const beginEdit = (row: EditableRow) => {
|
const beginEdit = (row: EditableRow) => {
|
||||||
setEditingOriginal(row.value);
|
setEditingOriginal(row.value);
|
||||||
setValue(row.value);
|
setValue(row.value);
|
||||||
@@ -240,8 +292,9 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
|
|||||||
Administración de listas
|
Administración de listas
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="mt-1 max-w-2xl">
|
<DialogDescription className="mt-1 max-w-2xl">
|
||||||
Los cambios se escriben primero en la hoja <strong>Listas</strong> y se
|
Los cambios que realices aquí se guardarán automáticamente en la hoja{` `}
|
||||||
sincronizan inmediatamente con Supabase para que aparezcan en toda la app.
|
<strong>Listas</strong> y se reflejarán de inmediato en las opciones disponibles
|
||||||
|
de la aplicación.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -348,7 +401,7 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder={`Buscar en ${meta.label.toLowerCase()}…`}
|
placeholder={`Buscar en ${meta.label}…`}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,7 +428,7 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{filteredRows.map((row) => (
|
{pageRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={normalizeOptionKey(`${category}:${row.value}`)}
|
key={normalizeOptionKey(`${category}:${row.value}`)}
|
||||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||||
@@ -420,6 +473,62 @@ export function ListManagerDialog({ open, onOpenChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import React, {
|
|||||||
import type { User as SupabaseUser } from "@supabase/supabase-js";
|
import type { User as SupabaseUser } from "@supabase/supabase-js";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getActiveTableroCdcAccess, type TableroCdcUserAccess } from "@/lib/accessControl";
|
import { getActiveTableroCdcAccess, type TableroCdcUserAccess } from "@/lib/accessControl";
|
||||||
|
import { recordTableroCdcOpen } from "@/lib/appMetrics";
|
||||||
|
|
||||||
function getRedirectTo(): string {
|
function getRedirectTo(): string {
|
||||||
if (typeof window === "undefined") return "/tablero-cdc/";
|
if (typeof window === "undefined") return "/tablero-cdc/";
|
||||||
@@ -103,6 +104,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(normalizeSupabaseUser(supabaseUser));
|
setUser(normalizeSupabaseUser(supabaseUser));
|
||||||
setAccess(nextAccess);
|
setAccess(nextAccess);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Métrica no bloqueante: una apertura por sesión autenticada/autorizada.
|
||||||
|
void recordTableroCdcOpen();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1007,6 +1007,47 @@ export async function addProjectTimeEntry(
|
|||||||
return mapDbProjectTimeEntry(data as DbProjectTimeEntry);
|
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> {
|
export async function deleteProjectTimeEntry(id: string): Promise<void> {
|
||||||
const cleanId = String(id || "").trim();
|
const cleanId = String(id || "").trim();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -69,6 +69,73 @@ const EMPTY_ADVANCED_FILTERS: AdvancedFilters = {
|
|||||||
cm: "",
|
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 {
|
function initials(name: string | null | undefined, email: string | null | undefined): string {
|
||||||
if (name) {
|
if (name) {
|
||||||
const parts = name.trim().split(/\s+/);
|
const parts = name.trim().split(/\s+/);
|
||||||
@@ -80,21 +147,18 @@ function initials(name: string | null | undefined, email: string | null | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BoardPage() {
|
export default function BoardPage() {
|
||||||
|
const [initialUiState] = useState(loadBoardUiState);
|
||||||
|
const hasMountedFilterReset = useRef(false);
|
||||||
|
const hasRestoredScroll = useRef(initialUiState.scrollY <= 0);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState(initialUiState.query);
|
||||||
const [filter, setFilter] = useState<BoardView>("todos");
|
const [filter, setFilter] = useState<BoardView>(initialUiState.filter);
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
|
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(initialUiState.advancedFilters);
|
||||||
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>({
|
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>(initialUiState.pageByFilter);
|
||||||
nuevos: 1,
|
|
||||||
todos: 1,
|
|
||||||
abiertos: 1,
|
|
||||||
cerrados: 1,
|
|
||||||
no_aprobados: 1,
|
|
||||||
});
|
|
||||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||||
const [tariffManagerOpen, setTariffManagerOpen] = useState(false);
|
const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen);
|
||||||
const [listManagerOpen, setListManagerOpen] = useState(false);
|
const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen);
|
||||||
const [timeProject, setTimeProject] = useState<Project | null>(null);
|
const [timeProject, setTimeProject] = useState<Project | null>(null);
|
||||||
const [reviewingBriefId, setReviewingBriefId] = useState<string | 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);
|
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, currentTotalItems);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hasMountedFilterReset.current) {
|
||||||
|
hasMountedFilterReset.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
||||||
}, [
|
}, [
|
||||||
query,
|
query,
|
||||||
filter,
|
|
||||||
advancedFilters.country,
|
advancedFilters.country,
|
||||||
advancedFilters.brand,
|
advancedFilters.brand,
|
||||||
advancedFilters.client,
|
advancedFilters.client,
|
||||||
advancedFilters.cm,
|
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(() => {
|
useEffect(() => {
|
||||||
if (currentPage > totalPages) {
|
if (currentPage > totalPages) {
|
||||||
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
|
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
|
||||||
|
|||||||
@@ -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.';
|
||||||
Reference in New Issue
Block a user