auth con avatar
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-18 22:31:22 -03:00
parent 5141a5c06c
commit 4f51c74717
24 changed files with 1122 additions and 47 deletions

View File

@@ -1,6 +1,44 @@
import { Link } from '@tanstack/react-router'
import { LogOut } from 'lucide-react'
import { useAuth } from '@/auth/AuthContext'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
function initials(name: string): string {
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
function UserAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name}
className="h-7 w-7 rounded-full object-cover border border-white/40"
/>
)
}
return (
<div
className="h-7 w-7 rounded-full flex items-center justify-center text-[11px] font-medium text-white border border-white/40"
style={{ background: '#F59845' }}
>
{initials(name)}
</div>
)
}
export function AppHeader() {
const { user, signOut } = useAuth()
return (
<header
style={{
@@ -21,19 +59,41 @@ export function AppHeader() {
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
/>
</Link>
<Link
to="/"
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
textDecoration: 'none',
}}
>
InQ ROI
</Link>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Link
to="/"
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
textDecoration: 'none',
}}
>
InQ ROI
</Link>
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rounded-full focus:outline-none focus:ring-2 focus:ring-white/60" title={user.name}>
<UserAvatar name={user.name} avatarUrl={user.avatarUrl} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<p className="text-sm font-medium leading-tight">{user.name}</p>
<p className="text-xs text-muted-foreground leading-tight mt-0.5">{user.email}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()} className="text-destructive focus:text-destructive">
<LogOut className="h-3.5 w-3.5" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[200px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-medium', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
}

View File

@@ -19,6 +19,7 @@ export interface Process {
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
}
export type ActivityType = 'task' | 'subprocess'

View File

@@ -81,6 +81,7 @@ export function ImportPage() {
groupId: targetGroupId,
tags: [],
ownerId: user!.id,
updatedBy: user!.id,
}
const activityElements = extractActivityElements(graph)

View File

@@ -7,8 +7,8 @@ import {
} from 'lucide-react'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { formatCurrency } from '@/lib/format'
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
import { formatCurrency, formatSimulationTimestamp } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader'
@@ -123,18 +123,21 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
process: Process
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, currentUserName, onShared }: {
process: ProcessWithUserNames
simulation: Simulation | null | undefined
showGroupChip?: boolean
groupName?: string
onDelete?: () => void
currentUserId: string
currentUserName: string
onShared?: () => void
}) {
const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
const isOwn = process.ownerId === currentUserId
const updatedByMe = process.updaterName === currentUserName
const fullDate = formatSimulationTimestamp(process.updatedAt)
// Cierra el menú al hacer click fuera de él
useEffect(() => {
@@ -164,9 +167,11 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete,
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<OwnershipBadge process={process} currentUserId={currentUserId} />
</div>
<div className="flex items-center gap-1 mt-0.5">
<div className="flex items-center gap-1 mt-0.5" title={`${fullDate.date} a las ${fullDate.time}`}>
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
<span className="text-[11px] text-muted-foreground">
Actualizado por {updatedByMe ? 'vos' : process.updaterName} · {relativeDate(process.updatedAt)}
</span>
</div>
{(process.tags.length > 0 || showGroupChip) && (
<div className="flex flex-wrap gap-1 mt-1">
@@ -373,12 +378,14 @@ function HomeView({
onImport,
ownerFilter,
currentUserId,
currentUserName,
onShared,
}: {
onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void
ownerFilter: 'all' | 'mine'
currentUserId: string
currentUserName: string
onShared: () => void
}) {
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore()
@@ -512,6 +519,7 @@ function HomeView({
showGroupChip
groupName={group?.name}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
)
@@ -614,11 +622,12 @@ function HomeView({
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
process: Process
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, currentUserName, onShared }: {
process: ProcessWithUserNames
showGroupChip?: boolean
groupName?: string
currentUserId: string
currentUserName: string
onShared: () => void
}) {
const { getLatestSimulation, deleteProcess } = useLibraryStore()
@@ -640,6 +649,7 @@ function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId,
groupName={groupName}
onDelete={() => deleteProcess(process.id)}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
)
@@ -651,12 +661,14 @@ function GroupView({
groupId,
onBack,
currentUserId,
currentUserName,
ownerFilter,
onShared,
}: {
groupId: string | null
onBack: () => void
currentUserId: string
currentUserName: string
ownerFilter: 'all' | 'mine'
onShared: () => void
}) {
@@ -721,6 +733,7 @@ function GroupView({
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
@@ -773,6 +786,7 @@ export function LibraryPage() {
}
const currentUserId = user!.id
const currentUserName = user!.name
const totalGroups = groups.length
return (
@@ -841,6 +855,7 @@ export function LibraryPage() {
onImport={handleImport}
ownerFilter={ownerFilter}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={() => showToast('Proceso compartido con el equipo')}
/>
) : (
@@ -848,6 +863,7 @@ export function LibraryPage() {
groupId={activeGroupId}
onBack={navigateHome}
currentUserId={currentUserId}
currentUserName={currentUserName}
ownerFilter={ownerFilter}
onShared={() => showToast('Proceso compartido con el equipo')}
/>

View File

@@ -38,6 +38,31 @@ function fromRow(row: Record<string, unknown>): Process {
createdAt: new Date(row.created_at as string).getTime(),
updatedAt: new Date(row.updated_at as string).getTime(),
ownerId: (row.owner_id as string) ?? '',
updatedBy: (row.updated_by as string) ?? '',
}
}
// Solo para display — no es parte del dominio puro. La resolución UUID → nombre
// es responsabilidad del repositorio, no del dominio (que solo conoce ownerId/updatedBy).
export interface ProcessWithUserNames extends Process {
ownerName: string
ownerAvatarUrl?: string
updaterName: string
}
interface UserRef {
name: string | null
avatar_url?: string | null
}
function fromRowWithUsers(row: Record<string, unknown>): ProcessWithUserNames {
const owner = row.owner as UserRef | null
const updater = row.updater as UserRef | null
return {
...fromRow(row),
ownerName: owner?.name ?? 'Desconocido',
ownerAvatarUrl: owner?.avatar_url ?? undefined,
updaterName: updater?.name ?? 'Desconocido',
}
}
@@ -57,13 +82,16 @@ export const supabaseProcessRepo = {
return fromRow(data)
},
async getAll(): Promise<Process[]> {
// JOIN con users para resolver owner/updater en una sola query (evita N+1).
// Si las FK owner_id/updated_by no están declaradas como relaciones en PostgREST,
// este select falla — en ese caso usar el fallback de dos queries + mapeo en memoria.
async getAll(): Promise<ProcessWithUserNames[]> {
const { data, error } = await supabase
.from('processes')
.select('*')
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)')
.order('updated_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRow)
return (data ?? []).map((row) => fromRowWithUsers(row as Record<string, unknown>))
},
async delete(id: string): Promise<void> {

View File

@@ -1,15 +1,15 @@
import { create } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { getSessionSafe } from '@/lib/supabase'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
import { supabaseGroupRepo } from '@/persistence/supabase/group-repo'
import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
import type { ProcessGroup, AppSettings, Simulation } from '@/domain/types'
interface LibraryState {
groups: ProcessGroup[]
processes: Process[]
processes: ProcessWithUserNames[]
settings: AppSettings
isLoading: boolean
error: string | null
@@ -23,7 +23,7 @@ interface LibraryState {
assignProcessToGroup: (processId: string, groupId: string | null, userId: string) => Promise<void>
getProcessesByGroup: (groupId: string | null) => Process[]
getProcessesByGroup: (groupId: string | null) => ProcessWithUserNames[]
getLatestSimulation: (processId: string) => Promise<Simulation | undefined>
updateSettings: (updates: Partial<Omit<AppSettings, 'id'>>) => Promise<void>