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 {
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
@@ -143,6 +143,8 @@ function isMissingTimeTableError(error: unknown) {
|
||||
export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDialogProps) {
|
||||
const { lists } = useAppLists();
|
||||
const [entries, setEntries] = useState<ProjectTimeEntry[]>([]);
|
||||
const [entriesProjectId, setEntriesProjectId] = useState<string | null>(null);
|
||||
const loadRequestRef = useRef(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -166,48 +168,62 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
||||
[country, lists.bus, projectCountries],
|
||||
);
|
||||
|
||||
// Nunca mostramos registros cargados para un proyecto distinto al que está abierto.
|
||||
// Esto evita el destello de datos del proyecto anterior mientras llega la nueva consulta.
|
||||
const projectEntries = entriesProjectId === project?.id ? entries : [];
|
||||
|
||||
const totalMinutes = useMemo(
|
||||
() => entries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
|
||||
[entries],
|
||||
() => projectEntries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
|
||||
[projectEntries],
|
||||
);
|
||||
const countryBreakdown = useMemo(
|
||||
() => buildBreakdown(entries, (entry) => entry.country),
|
||||
[entries],
|
||||
() => buildBreakdown(projectEntries, (entry) => entry.country),
|
||||
[projectEntries],
|
||||
);
|
||||
const taskBreakdown = useMemo(
|
||||
() => buildBreakdown(entries, (entry) => entry.taskName),
|
||||
[entries],
|
||||
() => buildBreakdown(projectEntries, (entry) => entry.taskName),
|
||||
[projectEntries],
|
||||
);
|
||||
const recentTasks = useMemo(
|
||||
() => dedupeOptions(entries.map((entry) => entry.taskName)).slice(0, 8),
|
||||
[entries],
|
||||
() => dedupeOptions(projectEntries.map((entry) => entry.taskName)).slice(0, 8),
|
||||
[projectEntries],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(entries.length / ENTRIES_PER_PAGE));
|
||||
const totalPages = Math.max(1, Math.ceil(projectEntries.length / ENTRIES_PER_PAGE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const pageStart = (safePage - 1) * ENTRIES_PER_PAGE;
|
||||
const visibleEntries = entries.slice(pageStart, pageStart + ENTRIES_PER_PAGE);
|
||||
const visibleStart = entries.length === 0 ? 0 : pageStart + 1;
|
||||
const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, entries.length);
|
||||
const visibleEntries = projectEntries.slice(pageStart, pageStart + ENTRIES_PER_PAGE);
|
||||
const visibleStart = projectEntries.length === 0 ? 0 : pageStart + 1;
|
||||
const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, projectEntries.length);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
if (!project?.id) return;
|
||||
const projectId = project?.id;
|
||||
if (!projectId) return;
|
||||
|
||||
const requestId = ++loadRequestRef.current;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const rows = await loadProjectTimeEntries(project.id);
|
||||
const rows = await loadProjectTimeEntries(projectId);
|
||||
if (requestId !== loadRequestRef.current) return;
|
||||
|
||||
setEntries(rows);
|
||||
setEntriesProjectId(projectId);
|
||||
|
||||
// Reconciliación silenciosa: al abrir Tiempo se vuelve a comprobar Excel.
|
||||
void syncProjectTimeToSheet(project.id)
|
||||
.then(() => setSheetSyncWarning(null))
|
||||
void syncProjectTimeToSheet(projectId)
|
||||
.then(() => {
|
||||
if (requestId === loadRequestRef.current) setSheetSyncWarning(null);
|
||||
})
|
||||
.catch((syncError) => {
|
||||
console.warn("No se pudo reconciliar el registro de horas con Excel:", syncError);
|
||||
});
|
||||
} catch (loadError) {
|
||||
if (requestId !== loadRequestRef.current) return;
|
||||
console.error("No se pudo cargar el tiempo del proyecto:", loadError);
|
||||
setEntries([]);
|
||||
setEntriesProjectId(projectId);
|
||||
setError(
|
||||
isMissingTimeTableError(loadError)
|
||||
? "El módulo de tiempo todavía no está instalado en Supabase. Ejecuta supabase_project_time_entries.sql."
|
||||
@@ -216,13 +232,18 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
||||
: "No se pudo cargar el desglose de tiempo.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestId === loadRequestRef.current) setLoading(false);
|
||||
}
|
||||
}, [project?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project?.id) return;
|
||||
|
||||
// Invalidar cualquier carga anterior antes de solicitar el nuevo proyecto.
|
||||
loadRequestRef.current += 1;
|
||||
setEntriesProjectId(null);
|
||||
setEntries([]);
|
||||
setLoading(true);
|
||||
setCountry(projectCountries[0] || "");
|
||||
setTaskName("");
|
||||
setHours("");
|
||||
@@ -598,24 +619,24 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Registros detallados</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entries.length === 0
|
||||
{projectEntries.length === 0
|
||||
? "Sin registros todavía."
|
||||
: `Mostrando ${visibleStart}-${visibleEnd} de ${entries.length}.`}
|
||||
: `Mostrando ${visibleStart}-${visibleEnd} de ${projectEntries.length}.`}
|
||||
</p>
|
||||
</div>
|
||||
{entries.length > ENTRIES_PER_PAGE && (
|
||||
{projectEntries.length > ENTRIES_PER_PAGE && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Página {safePage} de {totalPages}
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Cargando tiempo…
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
) : projectEntries.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<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>
|
||||
@@ -638,7 +659,7 @@ export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDi
|
||||
</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">
|
||||
<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;
|
||||
canControlPricingSummary: boolean;
|
||||
canManageTariffCatalog: boolean;
|
||||
canViewTeamHours: boolean;
|
||||
loginWithGoogle: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -199,6 +200,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => access?.canManageTariffCatalog === true,
|
||||
[access?.canManageTariffCatalog],
|
||||
);
|
||||
const canViewTeamHours = useMemo(
|
||||
() => access?.canViewTeamHours === true,
|
||||
[access?.canViewTeamHours],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -211,6 +216,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
canManageInternalPricing,
|
||||
canControlPricingSummary,
|
||||
canManageTariffCatalog,
|
||||
canViewTeamHours,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
}}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type TableroCdcUserAccess = {
|
||||
canManageInternalPricing: boolean;
|
||||
canControlPricingSummary: boolean;
|
||||
canManageTariffCatalog: boolean;
|
||||
canViewTeamHours: boolean;
|
||||
};
|
||||
|
||||
type AccessRow = {
|
||||
@@ -20,6 +21,7 @@ type AccessRow = {
|
||||
can_manage_internal_pricing?: boolean | null;
|
||||
can_control_pricing_summary?: boolean | null;
|
||||
can_manage_tariff_catalog?: boolean | null;
|
||||
can_view_team_hours?: boolean | null;
|
||||
};
|
||||
|
||||
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,
|
||||
canControlPricingSummary: row.can_control_pricing_summary === 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;
|
||||
|
||||
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
|
||||
.from("tablero_cdc_allowed_users")
|
||||
.select(
|
||||
@@ -63,18 +91,16 @@ export async function getActiveTableroCdcAccess(
|
||||
return normalizeAccessRow(queryWithCatalogPermission.data as AccessRow | null);
|
||||
}
|
||||
|
||||
const message = queryWithCatalogPermission.error.message || "";
|
||||
const catalogMessage = queryWithCatalogPermission.error.message || "";
|
||||
const isMissingCatalogPermission =
|
||||
message.includes("can_manage_tariff_catalog") ||
|
||||
message.includes("column") ||
|
||||
message.includes("schema cache");
|
||||
catalogMessage.includes("can_manage_tariff_catalog") ||
|
||||
catalogMessage.includes("column") ||
|
||||
catalogMessage.includes("schema cache");
|
||||
|
||||
if (!isMissingCatalogPermission) {
|
||||
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
|
||||
.from("tablero_cdc_allowed_users")
|
||||
.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,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
AlertCircle,
|
||||
BadgeDollarSign,
|
||||
DollarSign,
|
||||
@@ -32,6 +33,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { ProjectCard } from "@/components/board/ProjectCard";
|
||||
import { ProjectDialog } from "@/components/board/ProjectDialog";
|
||||
import { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog";
|
||||
import { TeamHoursDialog } from "@/components/board/TeamHoursDialog";
|
||||
import { BriefReviewCard } from "@/components/board/BriefReviewCard";
|
||||
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
||||
import {
|
||||
@@ -86,6 +88,7 @@ type PersistedBoardUiState = {
|
||||
pageByFilter: Record<BoardView, number>;
|
||||
tariffManagerOpen: boolean;
|
||||
listManagerOpen: boolean;
|
||||
teamHoursOpen: boolean;
|
||||
scrollY: number;
|
||||
};
|
||||
|
||||
@@ -97,6 +100,7 @@ function loadBoardUiState(): PersistedBoardUiState {
|
||||
pageByFilter: DEFAULT_PAGE_BY_FILTER,
|
||||
tariffManagerOpen: false,
|
||||
listManagerOpen: false,
|
||||
teamHoursOpen: false,
|
||||
scrollY: 0,
|
||||
};
|
||||
|
||||
@@ -112,14 +116,11 @@ function loadBoardUiState(): PersistedBoardUiState {
|
||||
: fallback.filter;
|
||||
|
||||
return {
|
||||
query: typeof parsed.query === "string" ? parsed.query : fallback.query,
|
||||
// Búsquedas y filtros avanzados son temporales: no se restauran tras una recarga
|
||||
// o descarte de pestaña para evitar que el tablero parezca incompleto al volver.
|
||||
query: "",
|
||||
filter: nextFilter,
|
||||
advancedFilters: {
|
||||
country: typeof parsed.advancedFilters?.country === "string" ? parsed.advancedFilters.country : "",
|
||||
brand: typeof parsed.advancedFilters?.brand === "string" ? parsed.advancedFilters.brand : "",
|
||||
client: typeof parsed.advancedFilters?.client === "string" ? parsed.advancedFilters.client : "",
|
||||
cm: typeof parsed.advancedFilters?.cm === "string" ? parsed.advancedFilters.cm : "",
|
||||
},
|
||||
advancedFilters: EMPTY_ADVANCED_FILTERS,
|
||||
pageByFilter: Object.fromEntries(
|
||||
Object.entries({ ...DEFAULT_PAGE_BY_FILTER, ...(parsed.pageByFilter || {}) }).map(([key, value]) => [
|
||||
key,
|
||||
@@ -128,6 +129,7 @@ function loadBoardUiState(): PersistedBoardUiState {
|
||||
) as Record<BoardView, number>,
|
||||
tariffManagerOpen: parsed.tariffManagerOpen === true,
|
||||
listManagerOpen: parsed.listManagerOpen === true,
|
||||
teamHoursOpen: parsed.teamHoursOpen === true,
|
||||
scrollY: Number.isFinite(Number(parsed.scrollY)) ? Math.max(0, Number(parsed.scrollY)) : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -159,6 +161,7 @@ export default function BoardPage() {
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||
const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen);
|
||||
const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen);
|
||||
const [teamHoursOpen, setTeamHoursOpen] = useState(initialUiState.teamHoursOpen);
|
||||
const [timeProject, setTimeProject] = useState<Project | null>(null);
|
||||
const [reviewingBriefId, setReviewingBriefId] = useState<string | null>(null);
|
||||
|
||||
@@ -190,9 +193,14 @@ export default function BoardPage() {
|
||||
canManageInternalPricing,
|
||||
canControlPricingSummary,
|
||||
canManageTariffCatalog,
|
||||
canViewTeamHours,
|
||||
logout,
|
||||
} = useAuth();
|
||||
const briefUnread = useBriefUnreadCount(Boolean(user));
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewTeamHours && teamHoursOpen) setTeamHoursOpen(false);
|
||||
}, [canViewTeamHours, teamHoursOpen]);
|
||||
const pricingSummaryParams = {
|
||||
tab: projectFilter,
|
||||
search: isBriefReviewView ? "" : query,
|
||||
@@ -266,12 +274,14 @@ export default function BoardPage() {
|
||||
const saveUiState = () => {
|
||||
try {
|
||||
const nextState: PersistedBoardUiState = {
|
||||
query,
|
||||
// Conservamos navegación/posición, pero no búsquedas ni filtros transitorios.
|
||||
query: "",
|
||||
filter,
|
||||
advancedFilters,
|
||||
advancedFilters: EMPTY_ADVANCED_FILTERS,
|
||||
pageByFilter,
|
||||
tariffManagerOpen,
|
||||
listManagerOpen,
|
||||
teamHoursOpen,
|
||||
scrollY: hasRestoredScroll.current ? window.scrollY : initialUiState.scrollY,
|
||||
};
|
||||
window.localStorage.setItem(BOARD_UI_STATE_KEY, JSON.stringify(nextState));
|
||||
@@ -293,7 +303,7 @@ export default function BoardPage() {
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, initialUiState.scrollY]);
|
||||
}, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, teamHoursOpen, initialUiState.scrollY]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedScrollY = initialUiState.scrollY;
|
||||
@@ -509,6 +519,18 @@ export default function BoardPage() {
|
||||
</DropdownMenuContent>
|
||||
</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 */}
|
||||
{canManageTariffCatalog && (
|
||||
<Button
|
||||
@@ -743,6 +765,10 @@ export default function BoardPage() {
|
||||
project={timeProject}
|
||||
/>
|
||||
|
||||
{canViewTeamHours && (
|
||||
<TeamHoursDialog open={teamHoursOpen} onOpenChange={setTeamHoursOpen} />
|
||||
)}
|
||||
|
||||
{canManageTariffCatalog && (
|
||||
<>
|
||||
<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