From 6d14e64f3310abb0f73fb9766412e012a543dbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Fri, 19 Jun 2026 19:56:13 -0300 Subject: [PATCH] feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature A — Ver procesos asociados a un recurso: - resource-repo.ts: getProcessCountsPerResource() (una query, N+1 evitado) - resource-repo.ts: getProcessesForResource() (on-demand al expandir) - ResourceCatalogPage: carga conteos en paralelo, columna "Procesos" con badge naranja clicable, fila expandida con links a /workspace/:id Feature B — Campo empresa en perfil: - 013_add_company_to_users.sql: ALTER TABLE users ADD COLUMN IF NOT EXISTS company text - AuthUser: agrega company? opcional - SupabaseAuthService: lee company y name de tabla users en onAuthStateChange - AuthContext: agrega updateUser() para reflejar cambios sin recargar - ProfileSheet.tsx: Sheet con nombre/empresa editables, email read-only - AppHeader: muestra empresa en dropdown + item "Editar perfil" 528 tests Vitest verdes. Build limpio. Co-Authored-By: Claude Sonnet 4.6 --- src/auth/AuthContext.tsx | 7 +- src/auth/SupabaseAuthService.ts | 15 +- src/auth/types.ts | 1 + src/components/AppHeader.tsx | 125 ++++++------ src/components/ProfileSheet.tsx | 91 +++++++++ .../resources/ResourceCatalogPage.tsx | 187 ++++++++++++------ src/persistence/supabase/resource-repo.ts | 42 ++++ .../migrations/013_add_company_to_users.sql | 3 + 8 files changed, 354 insertions(+), 117 deletions(-) create mode 100644 src/components/ProfileSheet.tsx create mode 100644 supabase/migrations/013_add_company_to_users.sql diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 5d3dc66..5fe64d0 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -7,6 +7,7 @@ interface AuthContextValue { loading: boolean signIn: () => Promise signOut: () => Promise + updateUser: (updates: Partial>) => void } const AuthContext = createContext(null) @@ -78,8 +79,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null) }, []) + const updateUser = useCallback((updates: Partial>) => { + setUser((prev) => prev ? { ...prev, ...updates } : prev) + }, []) + return ( - + {children} ) diff --git a/src/auth/SupabaseAuthService.ts b/src/auth/SupabaseAuthService.ts index 1c125c2..43ab55a 100644 --- a/src/auth/SupabaseAuthService.ts +++ b/src/auth/SupabaseAuthService.ts @@ -60,8 +60,19 @@ export class SupabaseAuthService implements AuthService { if (upsertError) console.error('Error al crear usuario en DB:', upsertError) } - // buildAuthUser es sincrónico — sin DB query, usa metadatos de la sesión - const user = this.buildAuthUser(session.user) + // Leer company y name editado desde la tabla users (pueden diferir del user_metadata de Google) + const { data: userRow } = await supabase + .from('users') + .select('company, name') + .eq('id', session.user.id) + .maybeSingle() + + const base = this.buildAuthUser(session.user) + const user: AuthUser = { + ...base, + company: (userRow?.company as string | null) ?? undefined, + name: (userRow?.name as string | null) ?? base.name, + } callback(user) } catch (err) { console.error('Error en onAuthStateChange:', err) diff --git a/src/auth/types.ts b/src/auth/types.ts index 78a67d2..e4c566d 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -4,6 +4,7 @@ export interface AuthUser { name: string avatarUrl?: string platformRole: 'platform_admin' | 'member' | 'guest' + company?: string } export interface AuthService { diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 6f4b90b..f007b7c 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react' import { Link } from '@tanstack/react-router' -import { LogOut } from 'lucide-react' +import { LogOut, UserPen } from 'lucide-react' import { useAuth } from '@/auth/AuthContext' import { DropdownMenu, @@ -9,6 +10,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' +import { ProfileSheet } from './ProfileSheet' function initials(name: string): string { const parts = name.trim().split(/\s+/) @@ -38,62 +40,75 @@ function UserAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string }) { export function AppHeader() { const { user, signOut } = useAuth() + const [profileOpen, setProfileOpen] = useState(false) return ( -
- - InQuality - -
- - InQ ROI + <> +
+ + InQuality - {user && ( - - - - - - -

{user.name}

-

{user.email}

-
- - signOut()} className="text-destructive focus:text-destructive"> - - Cerrar sesión - -
-
- )} -
-
+
+ + InQ ROI + + {user && ( + + + + + + +

{user.name}

+

{user.email}

+ {user.company && ( +

{user.company}

+ )} +
+ + setProfileOpen(true)}> + + Editar perfil + + + signOut()} className="text-destructive focus:text-destructive"> + + Cerrar sesión + +
+
+ )} +
+ + + + ) } diff --git a/src/components/ProfileSheet.tsx b/src/components/ProfileSheet.tsx new file mode 100644 index 0000000..c45c399 --- /dev/null +++ b/src/components/ProfileSheet.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react' +import { Check } from 'lucide-react' +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { supabase } from '@/lib/supabase' +import { useAuth } from '@/auth/AuthContext' + +interface ProfileSheetProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) { + const { user, updateUser } = useAuth() + const [name, setName] = useState('') + const [company, setCompany] = useState('') + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (open && user) { + setName(user.name) + setCompany(user.company ?? '') + } + }, [open, user]) + + async function handleSave() { + if (!user || !name.trim()) return + setSaving(true) + try { + const { error } = await supabase + .from('users') + .update({ name: name.trim(), company: company.trim() || null }) + .eq('id', user.id) + if (error) throw error + updateUser({ name: name.trim(), company: company.trim() || undefined }) + onOpenChange(false) + } catch (err) { + console.error('Error al guardar perfil:', err) + } finally { + setSaving(false) + } + } + + return ( + + + + Editar perfil + + +
+
+ + setName(e.target.value)} + placeholder="Tu nombre" + /> +
+ +
+ + setCompany(e.target.value)} + placeholder="InQuality" + /> +
+ +
+ + +
+
+ + + + + +
+
+ ) +} diff --git a/src/features/resources/ResourceCatalogPage.tsx b/src/features/resources/ResourceCatalogPage.tsx index 8345d55..ddbe66c 100644 --- a/src/features/resources/ResourceCatalogPage.tsx +++ b/src/features/resources/ResourceCatalogPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { useNavigate, useSearch } from '@tanstack/react-router' -import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft } from 'lucide-react' +import { useNavigate, useSearch, Link } from '@tanstack/react-router' +import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft, ChevronDown, ChevronRight } from 'lucide-react' import { AppHeader } from '@/components/AppHeader' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' @@ -24,6 +24,9 @@ export function ResourceCatalogPage() { const fromProcess = (search_ as { fromProcess?: string }).fromProcess const [fromProcessName, setFromProcessName] = useState(null) const [resources, setResources] = useState([]) + const [processCounts, setProcessCounts] = useState>({}) + const [expandedResourceId, setExpandedResourceId] = useState(null) + const [expandedProcesses, setExpandedProcesses] = useState>({}) const [isLoading, setIsLoading] = useState(true) const [search, setSearch] = useState('') const [typeFilter, setTypeFilter] = useState('all') @@ -44,8 +47,12 @@ export function ResourceCatalogPage() { async function load() { setIsLoading(true) try { - const data = await supabaseResourceRepo.getAllWithCreator() + const [data, counts] = await Promise.all([ + supabaseResourceRepo.getAllWithCreator(), + supabaseResourceRepo.getProcessCountsPerResource(), + ]) setResources(data) + setProcessCounts(counts) } finally { setIsLoading(false) } @@ -55,6 +62,18 @@ export function ResourceCatalogPage() { load() }, []) + async function toggleExpanded(resourceId: string) { + if (expandedResourceId === resourceId) { + setExpandedResourceId(null) + return + } + setExpandedResourceId(resourceId) + if (!expandedProcesses[resourceId]) { + const processes = await supabaseResourceRepo.getProcessesForResource(resourceId) + setExpandedProcesses((prev) => ({ ...prev, [resourceId]: processes })) + } + } + const filtered = resources.filter((r) => { const matchesName = r.name.toLowerCase().includes(search.toLowerCase()) const matchesType = typeFilter === 'all' || r.type === typeFilter @@ -182,68 +201,118 @@ export function ResourceCatalogPage() { Tipo Costo/h Creado por + Procesos Acciones - {filtered.map((resource) => ( - - -
- - {resource.name} -
-
- - - {RESOURCE_TYPE_LABELS[resource.type]} - - - {formatCostPerHour(resource.costPerHour)} - {resource.creatorName} - - {confirmingDeleteId === resource.id ? ( -
- ¿Eliminar? - - -
- ) : ( -
- {fromProcess && ( - - - - - - Volver al workspace para asignar este recurso a una actividad - - + {filtered.map((resource) => { + const count = processCounts[resource.id] ?? 0 + const isExpanded = expandedResourceId === resource.id + return ( + <> + + +
+ + {resource.name} +
+
+ + + {RESOURCE_TYPE_LABELS[resource.type]} + + + {formatCostPerHour(resource.costPerHour)} + {resource.creatorName} + + {count > 0 ? ( + + ) : ( + )} - - -
+
+ + {confirmingDeleteId === resource.id ? ( +
+ ¿Eliminar? + + +
+ ) : ( +
+ {fromProcess && ( + + + + + + Volver al workspace para asignar este recurso a una actividad + + + )} + + +
+ )} +
+
+ + {isExpanded && ( + + +

+ Procesos que usan este recurso +

+ {expandedProcesses[resource.id] ? ( +
+ {expandedProcesses[resource.id].map((p) => ( + + {p.name} + + ))} + {expandedProcesses[resource.id].length === 0 && ( + Sin procesos encontrados + )} +
+ ) : ( + Cargando... + )} +
+
)} - - - ))} + + ) + })}
)} diff --git a/src/persistence/supabase/resource-repo.ts b/src/persistence/supabase/resource-repo.ts index 739d98f..98adc73 100644 --- a/src/persistence/supabase/resource-repo.ts +++ b/src/persistence/supabase/resource-repo.ts @@ -56,6 +56,48 @@ export const supabaseResourceRepo = { return (data ?? []).map((row) => fromRowWithCreator(row as Record)) }, + // Devuelve un map resourceId → cantidad de procesos DISTINTOS que usan ese recurso. + // Una sola query para evitar N+1; agregación client-side con Set. + async getProcessCountsPerResource(): Promise> { + const { data, error } = await supabase + .from('activity_resource_assignments') + .select('resource_id, activity:activities!inner(process_id)') + if (error) throw error + if (!data) return {} + + const processSets: Record> = {} + for (const row of data) { + const resourceId = row.resource_id as string + const processId = (row.activity as unknown as { process_id: string }).process_id + if (!processSets[resourceId]) processSets[resourceId] = new Set() + processSets[resourceId].add(processId) + } + return Object.fromEntries( + Object.entries(processSets).map(([k, v]) => [k, v.size]) + ) + }, + + // Devuelve los procesos distintos que usan el recurso (llamada on-demand al expandir fila). + async getProcessesForResource(resourceId: string): Promise<{ id: string; name: string }[]> { + const { data, error } = await supabase + .from('activity_resource_assignments') + .select('activity:activities!inner(process:processes!inner(id, name))') + .eq('resource_id', resourceId) + if (error) throw error + if (!data) return [] + + const seen = new Set() + const result: { id: string; name: string }[] = [] + for (const row of data) { + const process = (row.activity as unknown as { process: { id: string; name: string } }).process + if (!seen.has(process.id)) { + seen.add(process.id) + result.push({ id: process.id, name: process.name }) + } + } + return result + }, + // No elimina si el recurso tiene asignaciones activas en activity_resource_assignments async delete(resourceId: string): Promise { const { count, error: countError } = await supabase diff --git a/supabase/migrations/013_add_company_to_users.sql b/supabase/migrations/013_add_company_to_users.sql new file mode 100644 index 0000000..188c13e --- /dev/null +++ b/supabase/migrations/013_add_company_to_users.sql @@ -0,0 +1,3 @@ +-- Agrega campo empresa al perfil de usuario (Sprint 5 Etapa 4). +-- Nullable sin default: no rompe filas existentes. +ALTER TABLE users ADD COLUMN IF NOT EXISTS company text;