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_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.
|
||||
@@ -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.
|
||||
|
||||
## 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="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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -579,56 +579,30 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
setFormError(null);
|
||||
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
||||
|
||||
if (isMultiCountryCreate) {
|
||||
const countries = selectedCreateCountries;
|
||||
const results = [];
|
||||
const createCountries = selectedCreateCountries;
|
||||
const createCountryManagers = dedupeOptions(
|
||||
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,
|
||||
const result =
|
||||
isEdit && project
|
||||
? await update(project.id, {
|
||||
...safeForm,
|
||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
: 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,
|
||||
});
|
||||
|
||||
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, {
|
||||
...safeForm,
|
||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||
})
|
||||
: await add({
|
||||
...safeForm,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||
});
|
||||
|
||||
showSaveToast(result);
|
||||
}
|
||||
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,9 +1046,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
? "Guardando…"
|
||||
: isEdit
|
||||
? "Guardar cambios"
|
||||
: isMultiCountryCreate
|
||||
? "Crear proyectos"
|
||||
: "Crear proyecto"}
|
||||
: "Crear proyecto"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
setEntries((current) => [saved, ...current]);
|
||||
setTaskName("");
|
||||
setHours("");
|
||||
setMinutes("");
|
||||
setNotes("");
|
||||
setPage(1);
|
||||
toast.success("Tiempo registrado", {
|
||||
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]);
|
||||
setPage(1);
|
||||
}
|
||||
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,21 +533,42 @@ 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>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!canSubmit || saving || loading}
|
||||
className="gap-1.5 rounded-full"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
<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>
|
||||
)}
|
||||
{saving ? "Guardando…" : "Agregar registro"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!canSubmit || saving || loading}
|
||||
className="gap-1.5 rounded-full"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isEditing ? (
|
||||
<Pencil className="h-4 w-4" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{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">
|
||||
{error}
|
||||
@@ -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,20 +794,36 @@ function TimeEntryRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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",
|
||||
deleting && "text-destructive",
|
||||
)}
|
||||
title="Eliminar registro"
|
||||
>
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
<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"
|
||||
size="icon"
|
||||
onClick={onDelete}
|
||||
disabled={deleting}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full text-muted-foreground hover:bg-destructive/10 hover:text-destructive",
|
||||
deleting && "text-destructive",
|
||||
)}
|
||||
title="Eliminar registro"
|
||||
>
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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 }));
|
||||
|
||||
@@ -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