This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
76
src/components/ui/dropdown-menu.tsx
Normal file
76
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -81,6 +81,7 @@ export function ImportPage() {
|
||||
groupId: targetGroupId,
|
||||
tags: [],
|
||||
ownerId: user!.id,
|
||||
updatedBy: user!.id,
|
||||
}
|
||||
|
||||
const activityElements = extractActivityElements(graph)
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user