feat: actualizar proyecto completo
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
Reference in New Issue
Block a user