feat: actualizar proyecto completo

This commit is contained in:
2026-09-01 14:26:30 -04:00
parent d8d45f142c
commit ca6b111bd0
12 changed files with 1434 additions and 40 deletions
+13
View File
@@ -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.
+30
View File
@@ -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.
+52
View File
@@ -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`.
+45 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
CalendarDays, CalendarDays,
ChevronLeft, ChevronLeft,
@@ -143,6 +143,8 @@ function isMissingTimeTableError(error: unknown) {
export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDialogProps) { export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDialogProps) {
const { lists } = useAppLists(); const { lists } = useAppLists();
const [entries, setEntries] = useState<ProjectTimeEntry[]>([]); const [entries, setEntries] = useState<ProjectTimeEntry[]>([]);
const [entriesProjectId, setEntriesProjectId] = useState<string | null>(null);
const loadRequestRef = useRef(0);
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);
@@ -166,48 +168,62 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
[country, lists.bus, projectCountries], [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( const totalMinutes = useMemo(
() => entries.reduce((sum, entry) => sum + entry.durationMinutes, 0), () => projectEntries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
[entries], [projectEntries],
); );
const countryBreakdown = useMemo( const countryBreakdown = useMemo(
() => buildBreakdown(entries, (entry) => entry.country), () => buildBreakdown(projectEntries, (entry) => entry.country),
[entries], [projectEntries],
); );
const taskBreakdown = useMemo( const taskBreakdown = useMemo(
() => buildBreakdown(entries, (entry) => entry.taskName), () => buildBreakdown(projectEntries, (entry) => entry.taskName),
[entries], [projectEntries],
); );
const recentTasks = useMemo( const recentTasks = useMemo(
() => dedupeOptions(entries.map((entry) => entry.taskName)).slice(0, 8), () => dedupeOptions(projectEntries.map((entry) => entry.taskName)).slice(0, 8),
[entries], [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 safePage = Math.min(page, totalPages);
const pageStart = (safePage - 1) * ENTRIES_PER_PAGE; const pageStart = (safePage - 1) * ENTRIES_PER_PAGE;
const visibleEntries = entries.slice(pageStart, pageStart + ENTRIES_PER_PAGE); const visibleEntries = projectEntries.slice(pageStart, pageStart + ENTRIES_PER_PAGE);
const visibleStart = entries.length === 0 ? 0 : pageStart + 1; const visibleStart = projectEntries.length === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, entries.length); const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, projectEntries.length);
const loadEntries = useCallback(async () => { const loadEntries = useCallback(async () => {
if (!project?.id) return; const projectId = project?.id;
if (!projectId) return;
const requestId = ++loadRequestRef.current;
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const rows = await loadProjectTimeEntries(project.id); const rows = await loadProjectTimeEntries(projectId);
if (requestId !== loadRequestRef.current) return;
setEntries(rows); setEntries(rows);
setEntriesProjectId(projectId);
// Reconciliación silenciosa: al abrir Tiempo se vuelve a comprobar Excel. // Reconciliación silenciosa: al abrir Tiempo se vuelve a comprobar Excel.
void syncProjectTimeToSheet(project.id) void syncProjectTimeToSheet(projectId)
.then(() => setSheetSyncWarning(null)) .then(() => {
if (requestId === loadRequestRef.current) setSheetSyncWarning(null);
})
.catch((syncError) => { .catch((syncError) => {
console.warn("No se pudo reconciliar el registro de horas con Excel:", syncError); console.warn("No se pudo reconciliar el registro de horas con Excel:", syncError);
}); });
} catch (loadError) { } catch (loadError) {
if (requestId !== loadRequestRef.current) return;
console.error("No se pudo cargar el tiempo del proyecto:", loadError); console.error("No se pudo cargar el tiempo del proyecto:", loadError);
setEntries([]); setEntries([]);
setEntriesProjectId(projectId);
setError( setError(
isMissingTimeTableError(loadError) isMissingTimeTableError(loadError)
? "El módulo de tiempo todavía no está instalado en Supabase. Ejecuta supabase_project_time_entries.sql." ? "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.", : "No se pudo cargar el desglose de tiempo.",
); );
} finally { } finally {
setLoading(false); if (requestId === loadRequestRef.current) setLoading(false);
} }
}, [project?.id]); }, [project?.id]);
useEffect(() => { useEffect(() => {
if (!open || !project?.id) return; 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] || ""); setCountry(projectCountries[0] || "");
setTaskName(""); setTaskName("");
setHours(""); setHours("");
@@ -598,24 +619,24 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
<div> <div>
<h3 className="text-sm font-semibold">Registros detallados</h3> <h3 className="text-sm font-semibold">Registros detallados</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{entries.length === 0 {projectEntries.length === 0
? "Sin registros todavía." ? "Sin registros todavía."
: `Mostrando ${visibleStart}-${visibleEnd} de ${entries.length}.`} : `Mostrando ${visibleStart}-${visibleEnd} de ${projectEntries.length}.`}
</p> </p>
</div> </div>
{entries.length > ENTRIES_PER_PAGE && ( {projectEntries.length > ENTRIES_PER_PAGE && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Página {safePage} de {totalPages} Página {safePage} de {totalPages}
</span> </span>
)} )}
</div> </div>
{loading && entries.length === 0 ? ( {loading && projectEntries.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Cargando tiempo Cargando tiempo
</div> </div>
) : entries.length === 0 ? ( ) : projectEntries.length === 0 ? (
<div className="px-4 py-10 text-center"> <div className="px-4 py-10 text-center">
<Clock3 className="mx-auto h-8 w-8 text-muted-foreground/40" /> <Clock3 className="mx-auto h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm font-medium">Aún no hay tiempo registrado</p> <p className="mt-2 text-sm font-medium">Aún no hay tiempo registrado</p>
@@ -638,7 +659,7 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
</div> </div>
)} )}
{entries.length > ENTRIES_PER_PAGE && ( {projectEntries.length > ENTRIES_PER_PAGE && (
<div className="flex items-center justify-between border-t border-border bg-muted/10 px-4 py-3"> <div className="flex items-center justify-between border-t border-border bg-muted/10 px-4 py-3">
<Button <Button
type="button" type="button"
+702
View File
@@ -0,0 +1,702 @@
import React, { useEffect, useMemo, useState } from "react";
import {
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock3,
History,
Loader2,
RefreshCw,
Search,
Sparkles,
Users,
} from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import { loadTeamHoursRecords, loadTeamHoursRoster, type TeamHoursRecord, type TeamHoursRosterMember } from "@/lib/teamHours";
const RECORDS_PER_PAGE = 8;
type PeriodPreset = "today" | "7d" | "30d" | "month" | "all" | "custom";
type PeopleFilter = "all" | "current" | "historical";
type SortMode = "hours" | "name" | "recent";
type CanonicalPerson = {
key: string;
name: string;
email: string;
current: boolean;
records: TeamHoursRecord[];
totalMinutes: number;
daysWithEntries: number;
averageMinutesPerLoggedDay: number;
lastDate: string;
};
type DailySummary = {
date: string;
minutes: number;
records: number;
projects: number;
};
function clean(value: unknown): string {
return String(value ?? "").trim().replace(/\s+/g, " ");
}
function normalizeName(value: string): string {
return clean(value)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokens(value: string): string[] {
return normalizeName(value).split(" ").filter((token) => token.length >= 3);
}
function findRosterMatch(name: string, email: string, roster: TeamHoursRosterMember[]) {
const normalizedEmail = clean(email).toLowerCase();
if (normalizedEmail) {
const byEmail = roster.find((member) => member.email === normalizedEmail);
if (byEmail) return byEmail;
}
const sourceName = normalizeName(name);
if (!sourceName) return null;
const exact = roster.find((member) => normalizeName(member.fullName) === sourceName);
if (exact) return exact;
const sourceTokens = new Set(tokens(name));
if (sourceTokens.size < 2) return null;
const candidates = roster
.map((member) => {
const memberTokens = new Set(tokens(member.fullName));
const shared = [...sourceTokens].filter((token) => memberTokens.has(token));
const subset =
shared.length === sourceTokens.size ||
shared.length === memberTokens.size;
return { member, shared: shared.length, subset };
})
.filter((candidate) => candidate.shared >= 2)
.sort((a, b) => Number(b.subset) - Number(a.subset) || b.shared - a.shared);
if (candidates.length !== 1 && candidates[0]?.shared === candidates[1]?.shared && candidates[0]?.subset === candidates[1]?.subset) {
return null;
}
return candidates[0]?.member || null;
}
function formatMinutes(totalMinutes: number, compact = false): string {
const safeMinutes = Math.max(0, Math.round(totalMinutes || 0));
const hours = Math.floor(safeMinutes / 60);
const minutes = safeMinutes % 60;
if (compact) {
if (!minutes) return `${hours} h`;
if (!hours) return `${minutes} min`;
return `${hours} h ${minutes} min`;
}
if (!minutes) return `${hours} h`;
return `${hours} h ${minutes.toString().padStart(2, "0")} min`;
}
function formatAverage(minutes: number): string {
if (!minutes) return "0 h";
return formatMinutes(minutes, true);
}
function localIsoDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function addDays(date: Date, amount: number) {
const next = new Date(date);
next.setDate(next.getDate() + amount);
return next;
}
function presetRange(preset: PeriodPreset): { from?: string; to?: string } {
const today = new Date();
const to = localIsoDate(today);
if (preset === "today") return { from: to, to };
if (preset === "7d") return { from: localIsoDate(addDays(today, -6)), to };
if (preset === "30d") return { from: localIsoDate(addDays(today, -29)), to };
if (preset === "month") return { from: localIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to };
return {};
}
function formatDateLabel(iso: string): string {
if (!iso) return "—";
const [year, month, day] = iso.split("-").map(Number);
if (!year || !month || !day) return iso;
return new Intl.DateTimeFormat("es", { day: "numeric", month: "short", year: "numeric" }).format(
new Date(year, month - 1, day),
);
}
function initials(name: string): string {
const parts = clean(name).split(" ").filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (parts[0] || "?").slice(0, 2).toUpperCase();
}
function buildPeople(records: TeamHoursRecord[], roster: TeamHoursRosterMember[]): CanonicalPerson[] {
const map = new Map<string, CanonicalPerson>();
for (const member of roster) {
map.set(`email:${member.email}`, {
key: `email:${member.email}`,
name: member.fullName,
email: member.email,
current: true,
records: [],
totalMinutes: 0,
daysWithEntries: 0,
averageMinutesPerLoggedDay: 0,
lastDate: "",
});
}
for (const record of records) {
const rosterMatch = findRosterMatch(record.workedByName, record.workedByEmail, roster);
const name = rosterMatch?.fullName || record.workedByName || "Sin nombre";
const email = rosterMatch?.email || record.workedByEmail;
const normalized = normalizeName(name) || "sin-nombre";
const key = rosterMatch ? `email:${rosterMatch.email}` : `name:${normalized}`;
const person = map.get(key) || {
key,
name,
email,
current: Boolean(rosterMatch),
records: [],
totalMinutes: 0,
daysWithEntries: 0,
averageMinutesPerLoggedDay: 0,
lastDate: "",
};
person.records.push(record);
person.totalMinutes += record.durationMinutes;
if (!person.lastDate || record.workDate > person.lastDate) person.lastDate = record.workDate;
map.set(key, person);
}
for (const person of map.values()) {
const days = new Set(person.records.map((record) => record.workDate));
person.daysWithEntries = days.size;
person.averageMinutesPerLoggedDay = days.size ? Math.round(person.totalMinutes / days.size) : 0;
person.records.sort((a, b) => b.workDate.localeCompare(a.workDate) || b.sourceKey.localeCompare(a.sourceKey));
}
return Array.from(map.values());
}
function buildDailySummaries(records: TeamHoursRecord[]): DailySummary[] {
const map = new Map<string, { minutes: number; records: number; projects: Set<string> }>();
for (const record of records) {
const current = map.get(record.workDate) || { minutes: 0, records: 0, projects: new Set<string>() };
current.minutes += record.durationMinutes;
current.records += 1;
current.projects.add(record.projectName);
map.set(record.workDate, current);
}
return Array.from(map.entries())
.map(([date, value]) => ({
date,
minutes: value.minutes,
records: value.records,
projects: value.projects.size,
}))
.sort((a, b) => b.date.localeCompare(a.date));
}
function SummaryCard({ icon, label, value, hint }: { icon: React.ReactNode; label: string; value: string; hint: string }) {
return (
<div className="rounded-2xl border border-border bg-card px-4 py-4 shadow-sm">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-primary/8 text-primary">{icon}</span>
{label}
</div>
<div className="mt-3 text-2xl font-semibold tracking-tight text-foreground">{value}</div>
<p className="mt-1 text-[11px] text-muted-foreground">{hint}</p>
</div>
);
}
function PersonRow({ person, selected, maxMinutes, onSelect }: { person: CanonicalPerson; selected: boolean; maxMinutes: number; onSelect: () => void }) {
const progress = maxMinutes > 0 ? Math.max(0, Math.min(100, (person.totalMinutes / maxMinutes) * 100)) : 0;
return (
<button
type="button"
onClick={onSelect}
className={`w-full rounded-2xl border p-3.5 text-left transition-all ${
selected
? "border-primary/35 bg-primary/[0.055] shadow-sm"
: "border-border bg-card hover:border-primary/20 hover:bg-muted/20"
}`}
>
<div className="flex items-start gap-3">
<Avatar className="h-10 w-10 flex-shrink-0">
<AvatarFallback className="bg-primary/10 text-xs font-semibold text-primary">{initials(person.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{person.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="h-5 rounded-full px-2 text-[10px] font-medium">
{person.current ? "Equipo actual" : "Histórico"}
</Badge>
{person.daysWithEntries > 0 && (
<span className="text-[10px] text-muted-foreground">{person.daysWithEntries} {person.daysWithEntries === 1 ? "día" : "días"}</span>
)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-foreground">{formatMinutes(person.totalMinutes, true)}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{formatAverage(person.averageMinutesPerLoggedDay)} / día</div>
</div>
</div>
<Progress value={progress} className="mt-3 h-1.5" />
</div>
</div>
</button>
);
}
export function TeamHoursDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const initialRange = useMemo(() => presetRange("7d"), []);
const [preset, setPreset] = useState<PeriodPreset>("7d");
const [from, setFrom] = useState(initialRange.from || "");
const [to, setTo] = useState(initialRange.to || "");
const [query, setQuery] = useState("");
const [peopleFilter, setPeopleFilter] = useState<PeopleFilter>("current");
const [sortMode, setSortMode] = useState<SortMode>("hours");
const [records, setRecords] = useState<TeamHoursRecord[]>([]);
const [roster, setRoster] = useState<TeamHoursRosterMember[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedKey, setSelectedKey] = useState<string>("");
const [selectedDay, setSelectedDay] = useState<string>("");
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[94vh] max-w-[1380px] overflow-hidden p-0 gap-0">
<div className="border-b border-border bg-gradient-to-r from-primary/[0.06] via-background to-background px-6 py-5 sm:px-7">
<DialogHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm">
<Users className="h-5 w-5" />
</span>
<div className="min-w-0">
<DialogTitle className="text-xl tracking-tight">Horas del equipo</DialogTitle>
<DialogDescription className="mt-1 max-w-2xl">
Consulta el tiempo registrado por persona, cuánto lleva por día y el detalle de sus actividades.
</DialogDescription>
</div>
</div>
<Button type="button" variant="outline" size="sm" className="h-9 self-start rounded-full px-3.5 shadow-sm lg:mr-6" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-1.5 h-4 w-4" />}
Actualizar
</Button>
</div>
</DialogHeader>
</div>
<div className="max-h-[calc(94vh-92px)] overflow-y-auto bg-muted/[0.12] px-5 py-5 sm:px-7 sm:py-6">
<div className="space-y-5">
<section className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="flex flex-wrap gap-2">
{([
["today", "Hoy"],
["7d", "7 días"],
["30d", "30 días"],
["month", "Este mes"],
["all", "Todo"],
] as const).map(([value, label]) => (
<Button
key={value}
type="button"
size="sm"
variant={preset === value ? "default" : "outline"}
className="h-8 rounded-full px-3 text-xs"
onClick={() => applyPreset(value)}
>
{label}
</Button>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:w-[430px]">
<label className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground">Desde</span>
<Input type="date" value={from} onChange={(event) => setCustomFrom(event.target.value)} className="h-9" />
</label>
<label className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground">Hasta</span>
<Input type="date" value={to} onChange={(event) => setCustomTo(event.target.value)} className="h-9" />
</label>
</div>
</div>
</section>
{error ? (
<div className="rounded-2xl border border-destructive/25 bg-destructive/5 px-5 py-7 text-center">
<div className="text-sm font-semibold text-foreground">No pudimos cargar las horas</div>
<p className="mt-1 text-xs text-muted-foreground">Intenta nuevamente en unos segundos.</p>
<Button type="button" variant="outline" size="sm" className="mt-4 rounded-full" onClick={() => void load()}>
<RefreshCw className="mr-1.5 h-4 w-4" /> Reintentar
</Button>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<SummaryCard icon={<Clock3 className="h-4 w-4" />} label="Horas registradas" value={formatMinutes(totalMinutes, true)} hint={`${visibleRecords.length.toLocaleString("es")} registros en el periodo`} />
<SummaryCard icon={<Users className="h-4 w-4" />} label="Personas con horas" value={String(peopleWithHours)} hint={`${filteredPeople.length} personas visibles`} />
<SummaryCard icon={<CalendarDays className="h-4 w-4" />} label="Días con actividad" value={String(activityDays)} hint="Días con al menos un registro" />
<SummaryCard icon={<Sparkles className="h-4 w-4" />} label="Promedio por persona/día" value={formatAverage(averagePersonDay)} hint="Sobre días que tienen registros" />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<section className="rounded-2xl border border-border bg-card shadow-sm">
<div className="border-b border-border p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar persona…" className="h-9 pl-9" />
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{([
["all", "Todos"],
["current", "Equipo actual"],
["historical", "Histórico"],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setPeopleFilter(value)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
peopleFilter === value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">{filteredPeople.length} personas</span>
<select
value={sortMode}
onChange={(event) => setSortMode(event.target.value as SortMode)}
className="h-8 rounded-lg border border-border bg-background px-2 text-[11px] text-foreground outline-none focus:border-primary/40"
>
<option value="hours">Más horas</option>
<option value="recent">Más recientes</option>
<option value="name">Nombre</option>
</select>
</div>
</div>
<div className="max-h-[620px] space-y-2 overflow-y-auto p-3">
{loading && !records.length ? (
Array.from({ length: 6 }).map((_, index) => <div key={index} className="h-[92px] animate-pulse rounded-2xl bg-muted" />)
) : filteredPeople.length ? (
filteredPeople.map((person) => (
<PersonRow key={person.key} person={person} selected={person.key === selectedKey} maxMinutes={maxPersonMinutes} onSelect={() => setSelectedKey(person.key)} />
))
) : (
<div className="px-4 py-12 text-center">
<Users className="mx-auto h-7 w-7 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium">No hay personas para mostrar</p>
<p className="mt-1 text-xs text-muted-foreground">Prueba cambiando la búsqueda o el filtro.</p>
</div>
)}
</div>
</section>
<section className="min-w-0 rounded-2xl border border-border bg-card shadow-sm">
{selectedPerson ? (
<div>
<div className="border-b border-border p-5 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initials(selectedPerson.name)}</AvatarFallback>
</Avatar>
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold tracking-tight">{selectedPerson.name}</h3>
<Badge variant="secondary" className="rounded-full text-[10px]">
{selectedPerson.current ? "Equipo actual" : "Histórico"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{selectedPerson.lastDate ? `Último registro: ${formatDateLabel(selectedPerson.lastDate)}` : "Sin registros en este periodo"}
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center lg:min-w-[390px]">
<div className="rounded-xl bg-muted/50 px-3 py-2.5">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Total</div>
<div className="mt-1 text-sm font-semibold">{formatMinutes(selectedPerson.totalMinutes, true)}</div>
</div>
<div className="rounded-xl bg-muted/50 px-3 py-2.5">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Promedio / día</div>
<div className="mt-1 text-sm font-semibold">{formatAverage(selectedPerson.averageMinutesPerLoggedDay)}</div>
</div>
<div className="rounded-xl bg-muted/50 px-3 py-2.5">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Días</div>
<div className="mt-1 text-sm font-semibold">{selectedPerson.daysWithEntries}</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-5 p-5 sm:p-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">Horas por día</h4>
<p className="mt-0.5 text-[11px] text-muted-foreground">Selecciona un día para ver únicamente sus actividades.</p>
</div>
{selectedDay && (
<Button type="button" variant="ghost" size="sm" className="h-7 rounded-full px-2 text-[11px]" onClick={() => setSelectedDay("")}>
Ver todos
</Button>
)}
</div>
<div className="max-h-[430px] space-y-2 overflow-y-auto pr-1">
{daily.length ? (
daily.map((day) => {
const width = maxDailyMinutes ? Math.max(5, (day.minutes / maxDailyMinutes) * 100) : 0;
const active = selectedDay === day.date;
return (
<button
key={day.date}
type="button"
onClick={() => setSelectedDay(active ? "" : day.date)}
className={`w-full rounded-xl border px-3.5 py-3 text-left transition-colors ${
active ? "border-primary/35 bg-primary/[0.055]" : "border-border bg-background hover:bg-muted/30"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold">{formatDateLabel(day.date)}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{day.projects} {day.projects === 1 ? "proyecto" : "proyectos"} · {day.records} {day.records === 1 ? "registro" : "registros"}</div>
</div>
<div className="text-sm font-semibold text-primary">{formatMinutes(day.minutes, true)}</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary/75 transition-all" style={{ width: `${width}%` }} />
</div>
</button>
);
})
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-12 text-center">
<CalendarDays className="mx-auto h-7 w-7 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium">Sin horas en este periodo</p>
<p className="mt-1 text-xs text-muted-foreground">Cuando registre tiempo, aparecerá aquí.</p>
</div>
)}
</div>
</div>
<div className="min-w-0">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">{selectedDay ? `Actividades · ${formatDateLabel(selectedDay)}` : "Actividades registradas"}</h4>
<p className="mt-0.5 text-[11px] text-muted-foreground">Proyecto, tarea, país y duración.</p>
</div>
<Badge variant="outline" className="rounded-full text-[10px]">{detailRecords.length} registros</Badge>
</div>
<div className="space-y-2">
{pagedRecords.length ? (
pagedRecords.map((record) => (
<div key={`${record.recordSource}:${record.id}:${record.sourceKey}`} className="rounded-xl border border-border bg-background px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-foreground">{record.projectName}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-muted-foreground">
<span>{record.taskName}</span>
{record.country && <span>· {record.country}</span>}
{!selectedDay && <span>· {formatDateLabel(record.workDate)}</span>}
</div>
</div>
<span className="flex-shrink-0 rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-semibold text-primary">{formatMinutes(record.durationMinutes, true)}</span>
</div>
{record.notes && record.notes.toLowerCase() !== "sin comentarios" && (
<p className="mt-2 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground">{record.notes}</p>
)}
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-12 text-center">
<CheckCircle2 className="mx-auto h-7 w-7 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium">No hay actividades para mostrar</p>
</div>
)}
</div>
{detailRecords.length > RECORDS_PER_PAGE && (
<div className="mt-4 flex items-center justify-between rounded-xl bg-muted/35 px-3 py-2">
<span className="text-[10px] text-muted-foreground">Página {safeRecordPage} de {totalRecordPages}</span>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-full" disabled={safeRecordPage <= 1} onClick={() => setRecordPage((page) => Math.max(1, page - 1))}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-full" disabled={safeRecordPage >= totalRecordPages} onClick={() => setRecordPage((page) => Math.min(totalRecordPages, page + 1))}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</div>
</div>
</div>
) : (
<div className="flex min-h-[440px] items-center justify-center px-6 text-center">
<div>
<History className="mx-auto h-9 w-9 text-muted-foreground/40" />
<h3 className="mt-3 text-sm font-semibold">Selecciona una persona</h3>
<p className="mt-1 text-xs text-muted-foreground">Aquí verás su resumen diario y las actividades registradas.</p>
</div>
</div>
)}
</section>
</div>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+6
View File
@@ -57,6 +57,7 @@ interface AuthContextValue {
canManageInternalPricing: boolean; canManageInternalPricing: boolean;
canControlPricingSummary: boolean; canControlPricingSummary: boolean;
canManageTariffCatalog: boolean; canManageTariffCatalog: boolean;
canViewTeamHours: boolean;
loginWithGoogle: () => Promise<void>; loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -199,6 +200,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => access?.canManageTariffCatalog === true, () => access?.canManageTariffCatalog === true,
[access?.canManageTariffCatalog], [access?.canManageTariffCatalog],
); );
const canViewTeamHours = useMemo(
() => access?.canViewTeamHours === true,
[access?.canViewTeamHours],
);
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -211,6 +216,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
canManageInternalPricing, canManageInternalPricing,
canControlPricingSummary, canControlPricingSummary,
canManageTariffCatalog, canManageTariffCatalog,
canViewTeamHours,
loginWithGoogle, loginWithGoogle,
logout, logout,
}} }}
+32 -6
View File
@@ -9,6 +9,7 @@ export type TableroCdcUserAccess = {
canManageInternalPricing: boolean; canManageInternalPricing: boolean;
canControlPricingSummary: boolean; canControlPricingSummary: boolean;
canManageTariffCatalog: boolean; canManageTariffCatalog: boolean;
canViewTeamHours: boolean;
}; };
type AccessRow = { type AccessRow = {
@@ -20,6 +21,7 @@ type AccessRow = {
can_manage_internal_pricing?: boolean | null; can_manage_internal_pricing?: boolean | null;
can_control_pricing_summary?: boolean | null; can_control_pricing_summary?: boolean | null;
can_manage_tariff_catalog?: boolean | null; can_manage_tariff_catalog?: boolean | null;
can_view_team_hours?: boolean | null;
}; };
function normalizeEmail(email: string | null | undefined): string { function normalizeEmail(email: string | null | undefined): string {
@@ -40,6 +42,7 @@ function normalizeAccessRow(row: AccessRow | null | undefined): TableroCdcUserAc
canManageInternalPricing: row.can_manage_internal_pricing === true, canManageInternalPricing: row.can_manage_internal_pricing === true,
canControlPricingSummary: row.can_control_pricing_summary === true, canControlPricingSummary: row.can_control_pricing_summary === true,
canManageTariffCatalog: row.can_manage_tariff_catalog === true, canManageTariffCatalog: row.can_manage_tariff_catalog === true,
canViewTeamHours: row.can_view_team_hours === true,
}; };
} }
@@ -50,6 +53,31 @@ export async function getActiveTableroCdcAccess(
if (!normalizedEmail) return null; if (!normalizedEmail) return null;
const queryWithTeamHoursPermission = await supabase
.from("tablero_cdc_allowed_users")
.select(
"email, full_name, role, is_active, can_delete_projects, can_manage_internal_pricing, can_control_pricing_summary, can_manage_tariff_catalog, can_view_team_hours",
)
.eq("email", normalizedEmail)
.eq("is_active", true)
.maybeSingle();
if (!queryWithTeamHoursPermission.error) {
return normalizeAccessRow(queryWithTeamHoursPermission.data as AccessRow | null);
}
const teamHoursMessage = queryWithTeamHoursPermission.error.message || "";
const isMissingTeamHoursPermission =
teamHoursMessage.includes("can_view_team_hours") ||
teamHoursMessage.includes("column") ||
teamHoursMessage.includes("schema cache");
if (!isMissingTeamHoursPermission) {
throw queryWithTeamHoursPermission.error;
}
// Compatibilidad durante el despliegue: si el permiso de Horas del equipo todavía
// no existe, el resto del Tablero sigue funcionando y ese acceso permanece oculto.
const queryWithCatalogPermission = await supabase const queryWithCatalogPermission = await supabase
.from("tablero_cdc_allowed_users") .from("tablero_cdc_allowed_users")
.select( .select(
@@ -63,18 +91,16 @@ export async function getActiveTableroCdcAccess(
return normalizeAccessRow(queryWithCatalogPermission.data as AccessRow | null); return normalizeAccessRow(queryWithCatalogPermission.data as AccessRow | null);
} }
const message = queryWithCatalogPermission.error.message || ""; const catalogMessage = queryWithCatalogPermission.error.message || "";
const isMissingCatalogPermission = const isMissingCatalogPermission =
message.includes("can_manage_tariff_catalog") || catalogMessage.includes("can_manage_tariff_catalog") ||
message.includes("column") || catalogMessage.includes("column") ||
message.includes("schema cache"); catalogMessage.includes("schema cache");
if (!isMissingCatalogPermission) { if (!isMissingCatalogPermission) {
throw queryWithCatalogPermission.error; throw queryWithCatalogPermission.error;
} }
// Compatibilidad durante el despliegue: la app sigue funcionando aunque el SQL nuevo
// todavía no se haya ejecutado. El botón Tarifario permanecerá oculto hasta entonces.
const fallbackQuery = await supabase const fallbackQuery = await supabase
.from("tablero_cdc_allowed_users") .from("tablero_cdc_allowed_users")
.select( .select(
+114
View File
@@ -0,0 +1,114 @@
import { supabase } from "@/lib/supabase";
export type TeamHoursSource = "app" | "excel_history";
export interface TeamHoursRecord {
id: string;
recordSource: TeamHoursSource;
sourceKey: string;
workDate: string;
projectName: string;
client: string;
country: string;
workedByName: string;
workedByEmail: string;
taskName: string;
durationMinutes: number;
notes: string;
}
export interface TeamHoursRosterMember {
email: string;
fullName: string;
}
type DbTeamHoursRecord = {
id: string;
record_source: TeamHoursSource | string | null;
source_key: string | null;
work_date: string;
project_name: string | null;
client: string | null;
country: string | null;
worked_by_name: string | null;
worked_by_email: string | null;
task_name: string | null;
duration_minutes: number | string | null;
notes: string | null;
};
type DbRosterMember = {
email: string | null;
full_name: string | null;
};
function clean(value: unknown): string {
return String(value ?? "").trim().replace(/\s+/g, " ");
}
function mapRecord(row: DbTeamHoursRecord): TeamHoursRecord {
return {
id: clean(row.id),
recordSource: row.record_source === "app" ? "app" : "excel_history",
sourceKey: clean(row.source_key),
workDate: clean(row.work_date),
projectName: clean(row.project_name) || "Sin nombre",
client: clean(row.client),
country: clean(row.country),
workedByName: clean(row.worked_by_name) || "Sin nombre",
workedByEmail: clean(row.worked_by_email).toLowerCase(),
taskName: clean(row.task_name) || "Sin tarea",
durationMinutes: Math.max(0, Math.round(Number(row.duration_minutes) || 0)),
notes: clean(row.notes),
};
}
export async function loadTeamHoursRecords({
from,
to,
}: {
from?: string;
to?: string;
} = {}): Promise<TeamHoursRecord[]> {
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<TeamHoursRosterMember[]> {
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);
}
+36 -10
View File
@@ -3,6 +3,7 @@ import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock3,
AlertCircle, AlertCircle,
BadgeDollarSign, BadgeDollarSign,
DollarSign, DollarSign,
@@ -32,6 +33,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard"; import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog"; import { ProjectDialog } from "@/components/board/ProjectDialog";
import { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog"; import { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog";
import { TeamHoursDialog } from "@/components/board/TeamHoursDialog";
import { BriefReviewCard } from "@/components/board/BriefReviewCard"; import { BriefReviewCard } from "@/components/board/BriefReviewCard";
import { SearchableSelect } from "@/components/board/SearchableSelect"; import { SearchableSelect } from "@/components/board/SearchableSelect";
import { import {
@@ -86,6 +88,7 @@ type PersistedBoardUiState = {
pageByFilter: Record<BoardView, number>; pageByFilter: Record<BoardView, number>;
tariffManagerOpen: boolean; tariffManagerOpen: boolean;
listManagerOpen: boolean; listManagerOpen: boolean;
teamHoursOpen: boolean;
scrollY: number; scrollY: number;
}; };
@@ -97,6 +100,7 @@ function loadBoardUiState(): PersistedBoardUiState {
pageByFilter: DEFAULT_PAGE_BY_FILTER, pageByFilter: DEFAULT_PAGE_BY_FILTER,
tariffManagerOpen: false, tariffManagerOpen: false,
listManagerOpen: false, listManagerOpen: false,
teamHoursOpen: false,
scrollY: 0, scrollY: 0,
}; };
@@ -112,14 +116,11 @@ function loadBoardUiState(): PersistedBoardUiState {
: fallback.filter; : fallback.filter;
return { 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, filter: nextFilter,
advancedFilters: { advancedFilters: EMPTY_ADVANCED_FILTERS,
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( pageByFilter: Object.fromEntries(
Object.entries({ ...DEFAULT_PAGE_BY_FILTER, ...(parsed.pageByFilter || {}) }).map(([key, value]) => [ Object.entries({ ...DEFAULT_PAGE_BY_FILTER, ...(parsed.pageByFilter || {}) }).map(([key, value]) => [
key, key,
@@ -128,6 +129,7 @@ function loadBoardUiState(): PersistedBoardUiState {
) as Record<BoardView, number>, ) as Record<BoardView, number>,
tariffManagerOpen: parsed.tariffManagerOpen === true, tariffManagerOpen: parsed.tariffManagerOpen === true,
listManagerOpen: parsed.listManagerOpen === true, listManagerOpen: parsed.listManagerOpen === true,
teamHoursOpen: parsed.teamHoursOpen === true,
scrollY: Number.isFinite(Number(parsed.scrollY)) ? Math.max(0, Number(parsed.scrollY)) : 0, scrollY: Number.isFinite(Number(parsed.scrollY)) ? Math.max(0, Number(parsed.scrollY)) : 0,
}; };
} catch (error) { } catch (error) {
@@ -159,6 +161,7 @@ export default function BoardPage() {
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null); const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen); const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen);
const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen); const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen);
const [teamHoursOpen, setTeamHoursOpen] = useState(initialUiState.teamHoursOpen);
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);
@@ -190,9 +193,14 @@ export default function BoardPage() {
canManageInternalPricing, canManageInternalPricing,
canControlPricingSummary, canControlPricingSummary,
canManageTariffCatalog, canManageTariffCatalog,
canViewTeamHours,
logout, logout,
} = useAuth(); } = useAuth();
const briefUnread = useBriefUnreadCount(Boolean(user)); const briefUnread = useBriefUnreadCount(Boolean(user));
useEffect(() => {
if (!canViewTeamHours && teamHoursOpen) setTeamHoursOpen(false);
}, [canViewTeamHours, teamHoursOpen]);
const pricingSummaryParams = { const pricingSummaryParams = {
tab: projectFilter, tab: projectFilter,
search: isBriefReviewView ? "" : query, search: isBriefReviewView ? "" : query,
@@ -266,12 +274,14 @@ export default function BoardPage() {
const saveUiState = () => { const saveUiState = () => {
try { try {
const nextState: PersistedBoardUiState = { const nextState: PersistedBoardUiState = {
query, // Conservamos navegación/posición, pero no búsquedas ni filtros transitorios.
query: "",
filter, filter,
advancedFilters, advancedFilters: EMPTY_ADVANCED_FILTERS,
pageByFilter, pageByFilter,
tariffManagerOpen, tariffManagerOpen,
listManagerOpen, listManagerOpen,
teamHoursOpen,
scrollY: hasRestoredScroll.current ? window.scrollY : initialUiState.scrollY, scrollY: hasRestoredScroll.current ? window.scrollY : initialUiState.scrollY,
}; };
window.localStorage.setItem(BOARD_UI_STATE_KEY, JSON.stringify(nextState)); window.localStorage.setItem(BOARD_UI_STATE_KEY, JSON.stringify(nextState));
@@ -293,7 +303,7 @@ export default function BoardPage() {
window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("pagehide", handlePageHide);
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, initialUiState.scrollY]); }, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, teamHoursOpen, initialUiState.scrollY]);
useEffect(() => { useEffect(() => {
const savedScrollY = initialUiState.scrollY; const savedScrollY = initialUiState.scrollY;
@@ -509,6 +519,18 @@ export default function BoardPage() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Team time overview — controlled by Supabase permission */}
{canViewTeamHours && (
<Button
type="button"
variant="outline"
onClick={() => setTeamHoursOpen(true)}
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
>
<Clock3 className="w-4 h-4" /> Horas del equipo
</Button>
)}
{/* Tariff administration — controlled by Supabase permission */} {/* Tariff administration — controlled by Supabase permission */}
{canManageTariffCatalog && ( {canManageTariffCatalog && (
<Button <Button
@@ -743,6 +765,10 @@ export default function BoardPage() {
project={timeProject} project={timeProject}
/> />
{canViewTeamHours && (
<TeamHoursDialog open={teamHoursOpen} onOpenChange={setTeamHoursOpen} />
)}
{canManageTariffCatalog && ( {canManageTariffCatalog && (
<> <>
<TariffManagerDialog <TariffManagerDialog
@@ -0,0 +1,185 @@
-- ============================================================================
-- TABLERO CDC - ETAPA 1 - HORAS HISTÓRICAS DEL EXCEL
-- Diseño ADITIVO y no destructivo.
--
-- Este script NO altera ni elimina datos de:
-- public.tablero_cdc_projects
-- public.tablero_cdc_project_time_entries
-- public.tablero_cdc_app_metrics_events
--
-- Crea una tabla separada para el histórico y una vista de lectura combinada.
-- ============================================================================
begin;
create table if not exists public.tablero_cdc_time_history (
id uuid primary key default gen_random_uuid(),
source_key text not null unique,
source_workbook text not null,
source_sheet text not null,
source_row integer not null check (source_row >= 3),
work_date date not null,
project_name text not null,
client text,
country text,
country_manager text,
worked_by_name text not null,
task_name text not null,
duration_minutes integer not null check (duration_minutes > 0),
request_date date,
requested_date date,
delivered_date date,
execution_status text,
timing_status text,
notes text not null default '',
source_month text,
source_month_number smallint,
source_year integer not null,
-- Se deja NULL en la migración inicial salvo que exista un match determinístico
-- validado posteriormente. No se crean proyectos falsos y no se fuerza fuzzy matching.
matched_project_id uuid references public.tablero_cdc_projects(id) on delete set null,
match_method text,
match_confidence numeric(5,4),
raw_data jsonb not null default '{}'::jsonb,
imported_at timestamptz not null default now(),
constraint tablero_cdc_time_history_project_not_blank
check (length(trim(project_name)) > 0),
constraint tablero_cdc_time_history_worker_not_blank
check (length(trim(worked_by_name)) > 0),
constraint tablero_cdc_time_history_task_not_blank
check (length(trim(task_name)) > 0),
constraint tablero_cdc_time_history_month_number_valid
check (source_month_number is null or source_month_number between 1 and 12),
constraint tablero_cdc_time_history_confidence_valid
check (match_confidence is null or (match_confidence >= 0 and match_confidence <= 1))
);
comment on table public.tablero_cdc_time_history is
'Histórico de horas proveniente de Registro horas. Se mantiene separado de los registros creados en la app para no alterar la operación, métricas ni sincronización actual.';
comment on column public.tablero_cdc_time_history.source_key is
'Clave idempotente de origen. Para esta migración: CD_Latam_Registro_Horas_2024|Registro horas|row:<n>.';
create index if not exists tablero_cdc_time_history_work_date_idx
on public.tablero_cdc_time_history(work_date desc);
create index if not exists tablero_cdc_time_history_worker_date_idx
on public.tablero_cdc_time_history(worked_by_name, work_date desc);
create index if not exists tablero_cdc_time_history_project_name_idx
on public.tablero_cdc_time_history(project_name);
create index if not exists tablero_cdc_time_history_year_idx
on public.tablero_cdc_time_history(source_year, work_date);
create index if not exists tablero_cdc_time_history_matched_project_idx
on public.tablero_cdc_time_history(matched_project_id, work_date desc)
where matched_project_id is not null;
alter table public.tablero_cdc_time_history enable row level security;
drop policy if exists "tablero_cdc_time_history_select_allowed"
on public.tablero_cdc_time_history;
create policy "tablero_cdc_time_history_select_allowed"
on public.tablero_cdc_time_history
for select
to authenticated
using (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
)
);
-- El frontend solo puede LEER el histórico. La carga se hace una sola vez mediante
-- el workflow administrativo con service role almacenado de forma segura en n8n.
revoke all on public.tablero_cdc_time_history from anon;
revoke insert, update, delete on public.tablero_cdc_time_history from authenticated;
grant select on public.tablero_cdc_time_history to authenticated;
-- Vista unificada para la segunda etapa (dashboard diario por persona).
-- security_invoker mantiene las políticas RLS de las tablas subyacentes.
create or replace view public.tablero_cdc_time_combined
with (security_invoker = true)
as
select
t.id,
'app'::text as record_source,
('app:' || t.id::text) as source_key,
null::integer as source_row,
t.work_date,
t.project_id,
p.title as project_name,
p.client,
t.country,
p.country_manager,
t.created_by_name as worked_by_name,
t.created_by_email as worked_by_email,
t.task_name,
t.duration_minutes,
null::date as request_date,
null::date as requested_date,
null::date as delivered_date,
null::text as execution_status,
null::text as timing_status,
t.notes,
extract(month from t.work_date)::integer as source_month_number,
extract(year from t.work_date)::integer as source_year,
t.created_at,
t.updated_at
from public.tablero_cdc_project_time_entries t
left join public.tablero_cdc_projects p
on p.id = t.project_id
union all
select
h.id,
'excel_history'::text as record_source,
h.source_key,
h.source_row,
h.work_date,
h.matched_project_id as project_id,
h.project_name,
h.client,
h.country,
h.country_manager,
h.worked_by_name,
null::text as worked_by_email,
h.task_name,
h.duration_minutes,
h.request_date,
h.requested_date,
h.delivered_date,
h.execution_status,
h.timing_status,
h.notes,
coalesce(h.source_month_number, extract(month from h.work_date)::integer) as source_month_number,
h.source_year,
h.imported_at as created_at,
h.imported_at as updated_at
from public.tablero_cdc_time_history h;
comment on view public.tablero_cdc_time_combined is
'Lectura unificada de horas actuales de la app + histórico importado del Excel. Diseñada como fuente de la segunda etapa de reportes por persona.';
grant select on public.tablero_cdc_time_combined to authenticated;
commit;
-- ============================================================================
-- VERIFICACIÓN INICIAL (ANTES DEL COMMIT DE n8n)
-- Debe mostrar 0 si esta es la primera ejecución del histórico.
-- ============================================================================
select
count(*) as historial_registros,
coalesce(sum(duration_minutes), 0) as historial_minutos
from public.tablero_cdc_time_history;
@@ -0,0 +1,44 @@
-- ============================================================================
-- TABLERO CDC — ETAPA 2 — HORAS DEL EQUIPO
-- Script aditivo y seguro.
--
-- Reutiliza la vista public.tablero_cdc_time_combined creada en la Etapa 1.
-- Solo agrega un RPC de lectura mínima para que el módulo pueda mostrar también
-- personas activas que todavía no han registrado horas en el periodo seleccionado.
-- NO modifica proyectos, horas, histórico, métricas, tarifarios ni listas.
-- ============================================================================
begin;
create or replace function public.tablero_cdc_get_team_hours_roster()
returns table (
email text,
full_name text
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.tablero_cdc_current_user_is_allowed() then
raise exception 'No autorizado';
end if;
return query
select
lower(trim(u.email)) as email,
coalesce(nullif(trim(u.full_name), ''), lower(trim(u.email))) as full_name
from public.tablero_cdc_allowed_users u
where u.is_active = true
order by coalesce(nullif(trim(u.full_name), ''), lower(trim(u.email)));
end;
$$;
revoke all on function public.tablero_cdc_get_team_hours_roster() from public;
grant execute on function public.tablero_cdc_get_team_hours_roster() to authenticated;
commit;
-- Verificación opcional desde una sesión autenticada de la app:
-- select * from public.tablero_cdc_get_team_hours_roster();
@@ -0,0 +1,175 @@
-- ============================================================================
-- TABLERO CDC — PERMISO INDIVIDUAL PARA "HORAS DEL EQUIPO"
-- Migración aditiva y segura.
--
-- Objetivo:
-- 1) Agregar tablero_cdc_allowed_users.can_view_team_hours.
-- 2) Mantener el acceso actual de los usuarios activos existentes la primera vez.
-- 3) Dejar nuevos usuarios SIN este permiso por defecto.
-- 4) Proteger los RPC usados por el módulo Horas del equipo.
--
-- No modifica proyectos, horas, histórico, métricas, tarifarios, listas ni briefs.
-- ============================================================================
begin;
-- Se ejecuta de forma idempotente. Solo en la PRIMERA instalación del permiso
-- se conserva el comportamiento actual dando acceso a los usuarios activos existentes.
do $$
declare
permission_was_missing boolean;
begin
select not exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'tablero_cdc_allowed_users'
and column_name = 'can_view_team_hours'
) into permission_was_missing;
if permission_was_missing then
alter table public.tablero_cdc_allowed_users
add column can_view_team_hours boolean not null default false;
update public.tablero_cdc_allowed_users
set can_view_team_hours = true,
updated_at = now()
where is_active = true;
end if;
end
$$;
comment on column public.tablero_cdc_allowed_users.can_view_team_hours is
'Controla si el usuario puede ver y consultar el módulo Horas del equipo. Los usuarios nuevos quedan sin acceso hasta que se les habilite explícitamente.';
-- Función central de permiso. Se usa desde el frontend y desde los RPC de datos.
create or replace function public.tablero_cdc_current_user_can_view_team_hours()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
and u.can_view_team_hours = true
);
$$;
revoke all on function public.tablero_cdc_current_user_can_view_team_hours() from public;
grant execute on function public.tablero_cdc_current_user_can_view_team_hours() to authenticated;
-- Roster de equipo: solo usuarios con el permiso pueden consultarlo.
create or replace function public.tablero_cdc_get_team_hours_roster()
returns table (
email text,
full_name text
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.tablero_cdc_current_user_can_view_team_hours() then
raise exception 'No autorizado';
end if;
return query
select
lower(trim(u.email)) as email,
coalesce(nullif(trim(u.full_name), ''), lower(trim(u.email))) as full_name
from public.tablero_cdc_allowed_users u
where u.is_active = true
order by coalesce(nullif(trim(u.full_name), ''), lower(trim(u.email)));
end;
$$;
revoke all on function public.tablero_cdc_get_team_hours_roster() from public;
grant execute on function public.tablero_cdc_get_team_hours_roster() to authenticated;
-- Lectura paginada de horas actuales + histórico. El acceso se valida en servidor,
-- de modo que ocultar el botón no sea el único control del módulo.
create or replace function public.tablero_cdc_get_team_hours_records(
p_from date default null,
p_to date default null,
p_limit integer default 1000,
p_offset integer default 0
)
returns table (
id uuid,
record_source text,
source_key text,
work_date date,
project_name text,
client text,
country text,
worked_by_name text,
worked_by_email text,
task_name text,
duration_minutes integer,
notes text
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.tablero_cdc_current_user_can_view_team_hours() then
raise exception 'No autorizado';
end if;
return query
select
c.id,
c.record_source,
c.source_key,
c.work_date,
c.project_name,
c.client,
c.country,
c.worked_by_name,
c.worked_by_email,
c.task_name,
c.duration_minutes,
c.notes
from public.tablero_cdc_time_combined c
where (p_from is null or c.work_date >= p_from)
and (p_to is null or c.work_date <= p_to)
order by c.work_date desc, c.id asc
limit least(greatest(coalesce(p_limit, 1000), 1), 1000)
offset greatest(coalesce(p_offset, 0), 0);
end;
$$;
revoke all on function public.tablero_cdc_get_team_hours_records(date, date, integer, integer) from public;
grant execute on function public.tablero_cdc_get_team_hours_records(date, date, integer, integer) to authenticated;
commit;
-- ============================================================================
-- ADMINISTRACIÓN DEL PERMISO
-- ============================================================================
-- Dar acceso:
-- update public.tablero_cdc_allowed_users
-- set can_view_team_hours = true, updated_at = now()
-- where lower(email) = lower('correo@gomezleemarketing.com');
--
-- Quitar acceso:
-- update public.tablero_cdc_allowed_users
-- set can_view_team_hours = false, updated_at = now()
-- where lower(email) = lower('correo@gomezleemarketing.com');
--
-- Ver estado actual:
select
email,
full_name,
role,
is_active,
can_view_team_hours
from public.tablero_cdc_allowed_users
order by full_name nulls last, email;