diff --git a/FIX_CARGA_Y_TIEMPO_2026-09-01.md b/FIX_CARGA_Y_TIEMPO_2026-09-01.md new file mode 100644 index 0000000..3e370c2 --- /dev/null +++ b/FIX_CARGA_Y_TIEMPO_2026-09-01.md @@ -0,0 +1,13 @@ +# Fix carga aparente + Tiempo — 2026-09-01 + +## 1. Proyectos que parecían faltar +El video confirmó que la vista local tenía la búsqueda `Carnation` restaurada automáticamente. Por eso el tablero mostraba 16 proyectos y un total tarifado filtrado de USD 5,760; la vista de producción, sin búsqueda, mostraba 259 proyectos y USD 122,160. + +Corrección: la persistencia conserva navegación, página, scroll y módulos abiertos, pero ya no restaura búsquedas ni filtros avanzados transitorios después de una recarga o descarte de pestaña. + +## 2. Destello de Tiempo del proyecto anterior +La UI React podía renderizar durante un instante el array de registros del proyecto anterior antes de que el efecto cargara el nuevo proyecto. + +Corrección: los registros quedan asociados explícitamente al project_id para el que fueron cargados y las respuestas asíncronas antiguas se invalidan. El dist híbrido aplica la misma protección con un token de solicitud. + +No se modifica Supabase, proyectos, tarifarios, briefs, listas, métricas ni el workflow de sincronización de horas. diff --git a/HORAS_DEL_EQUIPO_PERMISO_2026-09-01.md b/HORAS_DEL_EQUIPO_PERMISO_2026-09-01.md new file mode 100644 index 0000000..80fd1b5 --- /dev/null +++ b/HORAS_DEL_EQUIPO_PERMISO_2026-09-01.md @@ -0,0 +1,30 @@ +# Horas del equipo — Ajustes finales y permiso + +Esta versión mantiene intacta la lógica existente y aplica únicamente estos cambios al módulo **Horas del equipo**: + +- Se aumenta el espacio entre **Actualizar** y el botón **X** de cierre. +- El texto descriptivo pasa a ser: **“Consulta el tiempo registrado por persona, cuánto lleva por día y el detalle de sus actividades.”** +- Se agrega el permiso de Supabase `can_view_team_hours` en `tablero_cdc_allowed_users`. +- El botón **Horas del equipo** solo aparece a usuarios con `can_view_team_hours = true`. +- Los RPC de roster y registros vuelven a validar el permiso en Supabase antes de entregar información. +- En la primera instalación del permiso, los usuarios activos existentes conservan acceso. Los usuarios que se agreguen después quedan con `false` por defecto hasta que se les habilite. + +## Dar acceso + +```sql +update public.tablero_cdc_allowed_users +set can_view_team_hours = true, + updated_at = now() +where lower(email) = lower('correo@gomezleemarketing.com'); +``` + +## Quitar acceso + +```sql +update public.tablero_cdc_allowed_users +set can_view_team_hours = false, + updated_at = now() +where lower(email) = lower('correo@gomezleemarketing.com'); +``` + +Después de cambiar el permiso, la app vuelve a comprobarlo al recuperar el foco/visibilidad. Un refresh del navegador también lo aplica inmediatamente. diff --git a/HORAS_EQUIPO_ETAPA_2_2026-09-01.md b/HORAS_EQUIPO_ETAPA_2_2026-09-01.md new file mode 100644 index 0000000..8d2f9fa --- /dev/null +++ b/HORAS_EQUIPO_ETAPA_2_2026-09-01.md @@ -0,0 +1,52 @@ +# Horas del equipo — Etapa 2 + +## Objetivo + +Agregar al Tablero CDC una vista clara para consultar las horas registradas por persona y por día, combinando automáticamente: + +- Horas históricas migradas desde `Registro horas`. +- Horas nuevas creadas desde el módulo `Tiempo` de la app. + +## Experiencia de usuario + +El encabezado del Tablero incorpora el botón **Horas del equipo**. Al abrirlo se muestra un módulo visual integrado con el estilo de la aplicación que incluye: + +- Periodos rápidos: hoy, 7 días, 30 días, este mes y todo. +- Rango personalizado desde/hasta. +- Filtro por Equipo actual / Histórico / Todos. +- Búsqueda de personas. +- Orden por más horas, actividad reciente o nombre. +- Resumen del periodo: horas, personas con horas, días con actividad y promedio por persona/día. +- Resumen individual: total, promedio diario y días con registros. +- Desglose diario seleccionable. +- Detalle de proyecto, tarea, país, duración y nota. +- Paginación de actividades. + +El promedio diario se calcula sobre los días en los que la persona tiene al menos un registro; no presupone una jornada objetivo ni inventa feriados. + +## Identidad de personas + +Las horas nuevas traen correo y se vinculan directamente al roster activo. Para el histórico, la app realiza una conciliación conservadora por nombre: coincidencia exacta o al menos dos componentes del nombre coincidentes de forma no ambigua. Esto permite unificar, por ejemplo, variantes del nombre usadas antes y después de la app sin fusionar nombres ambiguos como `Thaía Suplencia Gerardo`. + +## Seguridad y datos + +- No se modifica `tablero_cdc_projects`. +- No se modifica `tablero_cdc_project_time_entries`. +- No se modifica `tablero_cdc_time_history`. +- No se modifica el workflow de sincronización de Registro horas. +- No se modifica el sistema de métricas. +- El módulo reutiliza `public.tablero_cdc_time_combined`, creada en la Etapa 1. +- El SQL de esta etapa solo crea `public.tablero_cdc_get_team_hours_roster()`, un RPC de lectura mínima que devuelve nombre/correo de usuarios activos después de validar que quien consulta tiene acceso al Tablero. + +## Instalación + +1. Ejecutar `supabase_tablero_cdc_team_hours_dashboard.sql` en Supabase. +2. Probar el `dist` incluido en el ZIP. +3. Entrar al Tablero y abrir **Horas del equipo**. +4. Confirmar que en **Equipo actual** aparecen también usuarios con 0 horas en el periodo. +5. Probar 7 días, 30 días, este mes y Todo. +6. Seleccionar una persona y un día; comprobar el detalle de actividades. + +## Compatibilidad + +El `dist` actual contiene `assets/team-hours-dashboard-v1.js` para mantener la misma estrategia de despliegue del ZIP recibido, que ya utiliza módulos complementarios para Tiempo, Listas, Briefs, métricas y persistencia. El código fuente también incluye la implementación nativa React (`TeamHoursDialog.tsx` + `teamHours.ts`) para que una compilación futura conserve la funcionalidad sin depender del parche del `dist`. diff --git a/src/components/board/ProjectTimeDialog.tsx b/src/components/board/ProjectTimeDialog.tsx index d3eb0d1..f436b07 100644 --- a/src/components/board/ProjectTimeDialog.tsx +++ b/src/components/board/ProjectTimeDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { CalendarDays, ChevronLeft, @@ -143,6 +143,8 @@ function isMissingTimeTableError(error: unknown) { export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDialogProps) { const { lists } = useAppLists(); const [entries, setEntries] = useState([]); + const [entriesProjectId, setEntriesProjectId] = useState(null); + const loadRequestRef = useRef(0); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); @@ -166,48 +168,62 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi [country, lists.bus, projectCountries], ); + // Nunca mostramos registros cargados para un proyecto distinto al que está abierto. + // Esto evita el destello de datos del proyecto anterior mientras llega la nueva consulta. + const projectEntries = entriesProjectId === project?.id ? entries : []; + const totalMinutes = useMemo( - () => entries.reduce((sum, entry) => sum + entry.durationMinutes, 0), - [entries], + () => projectEntries.reduce((sum, entry) => sum + entry.durationMinutes, 0), + [projectEntries], ); const countryBreakdown = useMemo( - () => buildBreakdown(entries, (entry) => entry.country), - [entries], + () => buildBreakdown(projectEntries, (entry) => entry.country), + [projectEntries], ); const taskBreakdown = useMemo( - () => buildBreakdown(entries, (entry) => entry.taskName), - [entries], + () => buildBreakdown(projectEntries, (entry) => entry.taskName), + [projectEntries], ); const recentTasks = useMemo( - () => dedupeOptions(entries.map((entry) => entry.taskName)).slice(0, 8), - [entries], + () => dedupeOptions(projectEntries.map((entry) => entry.taskName)).slice(0, 8), + [projectEntries], ); - const totalPages = Math.max(1, Math.ceil(entries.length / ENTRIES_PER_PAGE)); + const totalPages = Math.max(1, Math.ceil(projectEntries.length / ENTRIES_PER_PAGE)); const safePage = Math.min(page, totalPages); const pageStart = (safePage - 1) * ENTRIES_PER_PAGE; - const visibleEntries = entries.slice(pageStart, pageStart + ENTRIES_PER_PAGE); - const visibleStart = entries.length === 0 ? 0 : pageStart + 1; - const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, entries.length); + const visibleEntries = projectEntries.slice(pageStart, pageStart + ENTRIES_PER_PAGE); + const visibleStart = projectEntries.length === 0 ? 0 : pageStart + 1; + const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, projectEntries.length); const loadEntries = useCallback(async () => { - if (!project?.id) return; + const projectId = project?.id; + if (!projectId) return; + + const requestId = ++loadRequestRef.current; try { setLoading(true); setError(null); - const rows = await loadProjectTimeEntries(project.id); + const rows = await loadProjectTimeEntries(projectId); + if (requestId !== loadRequestRef.current) return; + setEntries(rows); + setEntriesProjectId(projectId); // Reconciliación silenciosa: al abrir Tiempo se vuelve a comprobar Excel. - void syncProjectTimeToSheet(project.id) - .then(() => setSheetSyncWarning(null)) + void syncProjectTimeToSheet(projectId) + .then(() => { + if (requestId === loadRequestRef.current) setSheetSyncWarning(null); + }) .catch((syncError) => { console.warn("No se pudo reconciliar el registro de horas con Excel:", syncError); }); } catch (loadError) { + if (requestId !== loadRequestRef.current) return; console.error("No se pudo cargar el tiempo del proyecto:", loadError); setEntries([]); + setEntriesProjectId(projectId); setError( isMissingTimeTableError(loadError) ? "El módulo de tiempo todavía no está instalado en Supabase. Ejecuta supabase_project_time_entries.sql." @@ -216,13 +232,18 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi : "No se pudo cargar el desglose de tiempo.", ); } finally { - setLoading(false); + if (requestId === loadRequestRef.current) setLoading(false); } }, [project?.id]); useEffect(() => { if (!open || !project?.id) return; + // Invalidar cualquier carga anterior antes de solicitar el nuevo proyecto. + loadRequestRef.current += 1; + setEntriesProjectId(null); + setEntries([]); + setLoading(true); setCountry(projectCountries[0] || ""); setTaskName(""); setHours(""); @@ -598,24 +619,24 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi

Registros detallados

- {entries.length === 0 + {projectEntries.length === 0 ? "Sin registros todavía." - : `Mostrando ${visibleStart}-${visibleEnd} de ${entries.length}.`} + : `Mostrando ${visibleStart}-${visibleEnd} de ${projectEntries.length}.`}

- {entries.length > ENTRIES_PER_PAGE && ( + {projectEntries.length > ENTRIES_PER_PAGE && ( Página {safePage} de {totalPages} )} - {loading && entries.length === 0 ? ( + {loading && projectEntries.length === 0 ? (
Cargando tiempo…
- ) : entries.length === 0 ? ( + ) : projectEntries.length === 0 ? (

Aún no hay tiempo registrado

@@ -638,7 +659,7 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
)} - {entries.length > ENTRIES_PER_PAGE && ( + {projectEntries.length > ENTRIES_PER_PAGE && (
+ ); +} + +export function TeamHoursDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const initialRange = useMemo(() => presetRange("7d"), []); + const [preset, setPreset] = useState("7d"); + const [from, setFrom] = useState(initialRange.from || ""); + const [to, setTo] = useState(initialRange.to || ""); + const [query, setQuery] = useState(""); + const [peopleFilter, setPeopleFilter] = useState("current"); + const [sortMode, setSortMode] = useState("hours"); + const [records, setRecords] = useState([]); + const [roster, setRoster] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedKey, setSelectedKey] = useState(""); + const [selectedDay, setSelectedDay] = useState(""); + const [recordPage, setRecordPage] = useState(1); + + const load = async () => { + if (!open) return; + + try { + setLoading(true); + setError(null); + const [nextRecords, nextRoster] = await Promise.all([ + loadTeamHoursRecords({ from: from || undefined, to: to || undefined }), + loadTeamHoursRoster(), + ]); + setRecords(nextRecords); + setRoster(nextRoster); + } catch (loadError) { + console.error("No se pudieron cargar las horas del equipo:", loadError); + setError(loadError instanceof Error ? loadError.message : "No pudimos cargar las horas ahora. Intenta nuevamente."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!open) return; + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, from, to]); + + const people = useMemo(() => buildPeople(records, roster), [records, roster]); + + const filteredPeople = useMemo(() => { + const q = normalizeName(query); + const next = people.filter((person) => { + if (peopleFilter === "current" && !person.current) return false; + if (peopleFilter === "historical" && person.current) return false; + if (!q) return true; + return normalizeName(person.name).includes(q) || normalizeName(person.email).includes(q); + }); + + return next.sort((a, b) => { + if (sortMode === "name") return a.name.localeCompare(b.name, "es", { sensitivity: "base" }); + if (sortMode === "recent") return (b.lastDate || "").localeCompare(a.lastDate || "") || b.totalMinutes - a.totalMinutes; + return b.totalMinutes - a.totalMinutes || a.name.localeCompare(b.name, "es", { sensitivity: "base" }); + }); + }, [people, peopleFilter, query, sortMode]); + + useEffect(() => { + if (!filteredPeople.length) { + setSelectedKey(""); + return; + } + if (!filteredPeople.some((person) => person.key === selectedKey)) { + setSelectedKey(filteredPeople[0].key); + } + }, [filteredPeople, selectedKey]); + + const selectedPerson = filteredPeople.find((person) => person.key === selectedKey) || null; + const daily = useMemo(() => buildDailySummaries(selectedPerson?.records || []), [selectedPerson]); + + useEffect(() => { + setSelectedDay(""); + setRecordPage(1); + }, [selectedKey, from, to]); + + const detailRecords = useMemo(() => { + const base = selectedPerson?.records || []; + return selectedDay ? base.filter((record) => record.workDate === selectedDay) : base; + }, [selectedDay, selectedPerson]); + + const totalRecordPages = Math.max(1, Math.ceil(detailRecords.length / RECORDS_PER_PAGE)); + const safeRecordPage = Math.min(recordPage, totalRecordPages); + const pagedRecords = detailRecords.slice((safeRecordPage - 1) * RECORDS_PER_PAGE, safeRecordPage * RECORDS_PER_PAGE); + + const visibleRecords = filteredPeople.flatMap((person) => person.records); + const totalMinutes = filteredPeople.reduce((sum, person) => sum + person.totalMinutes, 0); + const peopleWithHours = filteredPeople.filter((person) => person.totalMinutes > 0).length; + const activityDays = new Set(visibleRecords.map((record) => record.workDate)).size; + const teamPersonDays = filteredPeople.reduce((sum, person) => sum + person.daysWithEntries, 0); + const averagePersonDay = teamPersonDays ? Math.round(totalMinutes / teamPersonDays) : 0; + const maxPersonMinutes = Math.max(0, ...filteredPeople.map((person) => person.totalMinutes)); + const maxDailyMinutes = Math.max(0, ...daily.map((day) => day.minutes)); + + const applyPreset = (nextPreset: PeriodPreset) => { + setPreset(nextPreset); + const range = presetRange(nextPreset); + setFrom(range.from || ""); + setTo(range.to || ""); + }; + + const setCustomFrom = (value: string) => { + setPreset("custom"); + setFrom(value); + if (value && to && value > to) setTo(value); + }; + + const setCustomTo = (value: string) => { + setPreset("custom"); + setTo(value); + if (value && from && value < from) setFrom(value); + }; + + return ( + + +
+ +
+
+ + + +
+ Horas del equipo + + Consulta el tiempo registrado por persona, cuánto lleva por día y el detalle de sus actividades. + +
+
+ + +
+
+
+ +
+
+
+
+
+ {([ + ["today", "Hoy"], + ["7d", "7 días"], + ["30d", "30 días"], + ["month", "Este mes"], + ["all", "Todo"], + ] as const).map(([value, label]) => ( + + ))} +
+ +
+ + +
+
+
+ + {error ? ( +
+
No pudimos cargar las horas
+

Intenta nuevamente en unos segundos.

+ +
+ ) : ( + <> +
+ } label="Horas registradas" value={formatMinutes(totalMinutes, true)} hint={`${visibleRecords.length.toLocaleString("es")} registros en el periodo`} /> + } label="Personas con horas" value={String(peopleWithHours)} hint={`${filteredPeople.length} personas visibles`} /> + } label="Días con actividad" value={String(activityDays)} hint="Días con al menos un registro" /> + } label="Promedio por persona/día" value={formatAverage(averagePersonDay)} hint="Sobre días que tienen registros" /> +
+ +
+
+
+
+ + setQuery(event.target.value)} placeholder="Buscar persona…" className="h-9 pl-9" /> +
+ +
+ {([ + ["all", "Todos"], + ["current", "Equipo actual"], + ["historical", "Histórico"], + ] as const).map(([value, label]) => ( + + ))} +
+ +
+ {filteredPeople.length} personas + +
+
+ +
+ {loading && !records.length ? ( + Array.from({ length: 6 }).map((_, index) =>
) + ) : filteredPeople.length ? ( + filteredPeople.map((person) => ( + setSelectedKey(person.key)} /> + )) + ) : ( +
+ +

No hay personas para mostrar

+

Prueba cambiando la búsqueda o el filtro.

+
+ )} +
+
+ +
+ {selectedPerson ? ( +
+
+
+
+ + {initials(selectedPerson.name)} + +
+
+

{selectedPerson.name}

+ + {selectedPerson.current ? "Equipo actual" : "Histórico"} + +
+

+ {selectedPerson.lastDate ? `Último registro: ${formatDateLabel(selectedPerson.lastDate)}` : "Sin registros en este periodo"} +

+
+
+ +
+
+
Total
+
{formatMinutes(selectedPerson.totalMinutes, true)}
+
+
+
Promedio / día
+
{formatAverage(selectedPerson.averageMinutesPerLoggedDay)}
+
+
+
Días
+
{selectedPerson.daysWithEntries}
+
+
+
+
+ +
+
+
+
+

Horas por día

+

Selecciona un día para ver únicamente sus actividades.

+
+ {selectedDay && ( + + )} +
+ +
+ {daily.length ? ( + daily.map((day) => { + const width = maxDailyMinutes ? Math.max(5, (day.minutes / maxDailyMinutes) * 100) : 0; + const active = selectedDay === day.date; + return ( + + ); + }) + ) : ( +
+ +

Sin horas en este periodo

+

Cuando registre tiempo, aparecerá aquí.

+
+ )} +
+
+ +
+
+
+

{selectedDay ? `Actividades · ${formatDateLabel(selectedDay)}` : "Actividades registradas"}

+

Proyecto, tarea, país y duración.

+
+ {detailRecords.length} registros +
+ +
+ {pagedRecords.length ? ( + pagedRecords.map((record) => ( +
+
+
+
{record.projectName}
+
+ {record.taskName} + {record.country && · {record.country}} + {!selectedDay && · {formatDateLabel(record.workDate)}} +
+
+ {formatMinutes(record.durationMinutes, true)} +
+ {record.notes && record.notes.toLowerCase() !== "sin comentarios" && ( +

{record.notes}

+ )} +
+ )) + ) : ( +
+ +

No hay actividades para mostrar

+
+ )} +
+ + {detailRecords.length > RECORDS_PER_PAGE && ( +
+ Página {safeRecordPage} de {totalRecordPages} +
+ + +
+
+ )} +
+
+
+ ) : ( +
+
+ +

Selecciona una persona

+

Aquí verás su resumen diario y las actividades registradas.

+
+
+ )} +
+
+ + )} +
+
+
+
+ ); +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 395ceda..65ab64a 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -57,6 +57,7 @@ interface AuthContextValue { canManageInternalPricing: boolean; canControlPricingSummary: boolean; canManageTariffCatalog: boolean; + canViewTeamHours: boolean; loginWithGoogle: () => Promise; logout: () => Promise; } @@ -199,6 +200,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { () => access?.canManageTariffCatalog === true, [access?.canManageTariffCatalog], ); + const canViewTeamHours = useMemo( + () => access?.canViewTeamHours === true, + [access?.canViewTeamHours], + ); return ( { + const pageSize = 1000; + const rows: DbTeamHoursRecord[] = []; + + for (let offset = 0; ; offset += pageSize) { + const { data, error } = await supabase.rpc("tablero_cdc_get_team_hours_records", { + p_from: from || null, + p_to: to || null, + p_limit: pageSize, + p_offset: offset, + }); + + if (error) { + console.error("No se pudieron cargar las horas del equipo:", error); + throw new Error("No pudimos cargar las horas ahora. Intenta nuevamente."); + } + + const batch = (data || []) as DbTeamHoursRecord[]; + rows.push(...batch); + + if (batch.length < pageSize) break; + } + + return rows.map(mapRecord).filter((row) => row.durationMinutes > 0 && row.workDate); +} + +export async function loadTeamHoursRoster(): Promise { + const { data, error } = await supabase.rpc("tablero_cdc_get_team_hours_roster"); + + if (error) { + // Compatibilidad durante despliegue: el módulo sigue mostrando a quienes tengan horas + // aunque el pequeño RPC de roster todavía no se haya instalado. + console.warn("No se pudo cargar el roster completo del equipo:", error); + return []; + } + + return ((data || []) as DbRosterMember[]) + .map((row) => ({ + email: clean(row.email).toLowerCase(), + fullName: clean(row.full_name) || clean(row.email), + })) + .filter((row) => row.email && row.fullName); +} diff --git a/src/pages/BoardPage.tsx b/src/pages/BoardPage.tsx index 38243da..b275b7e 100644 --- a/src/pages/BoardPage.tsx +++ b/src/pages/BoardPage.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, + Clock3, AlertCircle, BadgeDollarSign, DollarSign, @@ -32,6 +33,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { ProjectCard } from "@/components/board/ProjectCard"; import { ProjectDialog } from "@/components/board/ProjectDialog"; import { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog"; +import { TeamHoursDialog } from "@/components/board/TeamHoursDialog"; import { BriefReviewCard } from "@/components/board/BriefReviewCard"; import { SearchableSelect } from "@/components/board/SearchableSelect"; import { @@ -86,6 +88,7 @@ type PersistedBoardUiState = { pageByFilter: Record; tariffManagerOpen: boolean; listManagerOpen: boolean; + teamHoursOpen: boolean; scrollY: number; }; @@ -97,6 +100,7 @@ function loadBoardUiState(): PersistedBoardUiState { pageByFilter: DEFAULT_PAGE_BY_FILTER, tariffManagerOpen: false, listManagerOpen: false, + teamHoursOpen: false, scrollY: 0, }; @@ -112,14 +116,11 @@ function loadBoardUiState(): PersistedBoardUiState { : fallback.filter; return { - query: typeof parsed.query === "string" ? parsed.query : fallback.query, + // Búsquedas y filtros avanzados son temporales: no se restauran tras una recarga + // o descarte de pestaña para evitar que el tablero parezca incompleto al volver. + 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 : "", - }, + advancedFilters: EMPTY_ADVANCED_FILTERS, pageByFilter: Object.fromEntries( Object.entries({ ...DEFAULT_PAGE_BY_FILTER, ...(parsed.pageByFilter || {}) }).map(([key, value]) => [ key, @@ -128,6 +129,7 @@ function loadBoardUiState(): PersistedBoardUiState { ) as Record, tariffManagerOpen: parsed.tariffManagerOpen === true, listManagerOpen: parsed.listManagerOpen === true, + teamHoursOpen: parsed.teamHoursOpen === true, scrollY: Number.isFinite(Number(parsed.scrollY)) ? Math.max(0, Number(parsed.scrollY)) : 0, }; } catch (error) { @@ -159,6 +161,7 @@ export default function BoardPage() { const [deletingProjectId, setDeletingProjectId] = useState(null); const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen); const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen); + const [teamHoursOpen, setTeamHoursOpen] = useState(initialUiState.teamHoursOpen); const [timeProject, setTimeProject] = useState(null); const [reviewingBriefId, setReviewingBriefId] = useState(null); @@ -190,9 +193,14 @@ export default function BoardPage() { canManageInternalPricing, canControlPricingSummary, canManageTariffCatalog, + canViewTeamHours, logout, } = useAuth(); const briefUnread = useBriefUnreadCount(Boolean(user)); + + useEffect(() => { + if (!canViewTeamHours && teamHoursOpen) setTeamHoursOpen(false); + }, [canViewTeamHours, teamHoursOpen]); const pricingSummaryParams = { tab: projectFilter, search: isBriefReviewView ? "" : query, @@ -266,12 +274,14 @@ export default function BoardPage() { const saveUiState = () => { try { const nextState: PersistedBoardUiState = { - query, + // Conservamos navegación/posición, pero no búsquedas ni filtros transitorios. + query: "", filter, - advancedFilters, + advancedFilters: EMPTY_ADVANCED_FILTERS, pageByFilter, tariffManagerOpen, listManagerOpen, + teamHoursOpen, scrollY: hasRestoredScroll.current ? window.scrollY : initialUiState.scrollY, }; window.localStorage.setItem(BOARD_UI_STATE_KEY, JSON.stringify(nextState)); @@ -293,7 +303,7 @@ export default function BoardPage() { window.removeEventListener("pagehide", handlePageHide); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, initialUiState.scrollY]); + }, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, teamHoursOpen, initialUiState.scrollY]); useEffect(() => { const savedScrollY = initialUiState.scrollY; @@ -509,6 +519,18 @@ export default function BoardPage() { + {/* Team time overview — controlled by Supabase permission */} + {canViewTeamHours && ( + + )} + {/* Tariff administration — controlled by Supabase permission */} {canManageTariffCatalog && (