Compare commits
2 Commits
e8a4a1b3e0
...
7ffab83caf
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ffab83caf | |||
| 6d14e64f33 |
@@ -29,6 +29,12 @@
|
||||
|
||||
## Hallazgos resueltos durante el sprint
|
||||
|
||||
### [RESOLVED] Etapas 3 + 3B — Heatmap gradiente + toggle + persistencia
|
||||
|
||||
**Estado:** OK formal del director — 2026-06-19.
|
||||
**Entregado:** gradiente 4 stops (#10b981→#f59e0b→#f97316→#ef4444), modos percentile/minmax, `useHeatmapMode` hook con localStorage, HeatmapModeToggle, leyenda PDF 4 stops, zoom controls movidos a bottom-left.
|
||||
**Lección operativa:** Dokploy con Autodeploy desactivado requiere `git push origin main` explícito antes de validar en producción. Los commits `a65bec4` y `e8a4a1b` estaban locales sin pushear — Dokploy desplegaba Etapa 2 mientras se validaba Etapa 3. La validación fallida fue contra código desactualizado, no contra bugs reales.
|
||||
|
||||
### [RESOLVED] Etapa 2 — Zoom BPMN fix + controles + skeleton
|
||||
|
||||
**Estado:** OK formal del director — 2026-06-19.
|
||||
|
||||
273
sprints/sprint-5/ETAPA_4_PROMPT.md
Normal file
273
sprints/sprint-5/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Sprint 5 — Etapa 4: Procesos por recurso + campo empresa en perfil
|
||||
|
||||
## Contexto
|
||||
|
||||
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
|
||||
Etapas 1, 2, 3 y 3B completadas con OK formal. Esta etapa agrega dos features pequeñas de producto.
|
||||
|
||||
---
|
||||
|
||||
## Feature A — Ver procesos asociados a un recurso
|
||||
|
||||
### Contexto actual
|
||||
|
||||
`ResourceCatalogPage` tiene 5 columnas: Recurso, Tipo, Costo/h, Creado por, Acciones. El botón Eliminar ya protege contra borrado de recursos en uso (verifica `activity_resource_assignments`), pero el usuario no puede ver qué procesos usan el recurso antes de intentar eliminarlo.
|
||||
|
||||
### Implementación
|
||||
|
||||
**Paso 1 — Nueva query en `resource-repo.ts`:**
|
||||
|
||||
Agregar un método `getProcessCountsPerResource()` que devuelva un map de `resourceId → cantidad de procesos distintos`. Implementar con una sola query (no una por recurso) para evitar N+1:
|
||||
|
||||
```ts
|
||||
async getProcessCountsPerResource(): Promise<Record<string, number>> {
|
||||
const { data, error } = await supabase
|
||||
.from('activity_resource_assignments')
|
||||
.select('resource_id, activity:activities!inner(process_id)')
|
||||
|
||||
if (error) throw error
|
||||
if (!data) return {}
|
||||
|
||||
// Agregar client-side: contar procesos DISTINTOS por recurso
|
||||
const processSets: Record<string, Set<string>> = {}
|
||||
for (const row of data) {
|
||||
const resourceId = row.resource_id
|
||||
const processId = (row.activity 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])
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Paso 2 — Cargar los conteos en `ResourceCatalogPage`:**
|
||||
|
||||
Al cargar los recursos (`useEffect` de mount), también llamar a `getProcessCountsPerResource()` en paralelo:
|
||||
|
||||
```ts
|
||||
const [resources, processCounts] = await Promise.all([
|
||||
supabaseResourceRepo.getAllWithCreator(),
|
||||
supabaseResourceRepo.getProcessCountsPerResource(),
|
||||
])
|
||||
```
|
||||
|
||||
Guardar `processCounts` en estado local del componente.
|
||||
|
||||
**Paso 3 — Nueva columna "Procesos" en la tabla:**
|
||||
|
||||
Agregar columna entre "Creado por" y "Acciones":
|
||||
|
||||
- Header: "Procesos"
|
||||
- Si `processCounts[resource.id] === 0` o undefined: mostrar "—" en color muted
|
||||
- Si `processCounts[resource.id] > 0`: mostrar badge clicable con el número
|
||||
|
||||
```tsx
|
||||
{count > 0 ? (
|
||||
<button
|
||||
onClick={() => toggleExpanded(resource.id)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-md px-2 py-0.5 transition-colors"
|
||||
>
|
||||
{count} {count === 1 ? 'proceso' : 'procesos'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
```
|
||||
|
||||
El color naranja InQ (`text-orange-600`, `bg-orange-50`) es correcto para indicar "hay uso activo".
|
||||
|
||||
**Paso 4 — Expansión inline con lista de procesos:**
|
||||
|
||||
Al hacer click en el badge, expandir una fila adicional debajo con los nombres de los procesos.
|
||||
|
||||
Para esto necesitás una query adicional que devuelva los nombres — la query anterior solo devuelve conteos. Agregar un segundo método en `resource-repo.ts`:
|
||||
|
||||
```ts
|
||||
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 []
|
||||
|
||||
// Deduplicar por proceso id
|
||||
const seen = new Set<string>()
|
||||
const result: { id: string; name: string }[] = []
|
||||
for (const row of data) {
|
||||
const process = (row.activity as any).process
|
||||
if (!seen.has(process.id)) {
|
||||
seen.add(process.id)
|
||||
result.push({ id: process.id, name: process.name })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Esta query se llama **on demand** (solo cuando el usuario expande la fila), no al cargar la página.
|
||||
|
||||
La fila expandida muestra:
|
||||
- Lista de nombres de proceso, cada uno como link a `/workspace/:id`
|
||||
- Usar `<Link>` de TanStack Router
|
||||
|
||||
```tsx
|
||||
// Fila expandida (colspan completo de la tabla):
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 pb-3 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{processes.map(p => (
|
||||
<Link key={p.id} to="/workspace/$processId" params={{ processId: p.id }}
|
||||
className="text-xs text-blue-600 hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature B — Campo empresa en perfil de usuario
|
||||
|
||||
### Contexto actual
|
||||
|
||||
`AuthUser` en `/src/auth/types.ts` tiene: `id, email, name, avatarUrl?, platformRole`. Sin `company`.
|
||||
|
||||
`buildAuthUser()` construye el objeto desde OAuth `user_metadata` de Google. No lee la tabla `users` de Supabase directamente.
|
||||
|
||||
El AppHeader dropdown muestra: avatar + nombre + email + botón "Cerrar sesión".
|
||||
|
||||
### Implementación
|
||||
|
||||
**Paso 1 — Migración en Supabase:**
|
||||
|
||||
Crear archivo de migración `supabase/migrations/006_add_company_to_users.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS company text;
|
||||
```
|
||||
|
||||
Ejecutar en Supabase Studio SQL Editor. Verificar que no rompe datos existentes (la columna es nullable sin default).
|
||||
|
||||
**Paso 2 — Extender `AuthUser`:**
|
||||
|
||||
En `/src/auth/types.ts`:
|
||||
```ts
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
platformRole: 'platform_admin' | 'member' | 'guest'
|
||||
company?: string // 🆕 nullable, viene de tabla users
|
||||
}
|
||||
```
|
||||
|
||||
**Paso 3 — Leer `company` desde la tabla `users`:**
|
||||
|
||||
`buildAuthUser()` actualmente solo usa `user_metadata` de OAuth. Para `company` necesitamos leer la tabla `users` de Supabase (que ya existe y se populó en Sprint 4).
|
||||
|
||||
La forma más limpia: después de que el usuario autentique, hacer una query puntual a `users` para obtener el `company`. Este fetch puede ir en el `onAuthStateChange` callback, justo después de construir el `AuthUser` base:
|
||||
|
||||
```ts
|
||||
// En AuthContext o SupabaseAuthService, después de obtener el usuario de OAuth:
|
||||
const { data: userRow } = await supabase
|
||||
.from('users')
|
||||
.select('company, name')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
const authUser: AuthUser = {
|
||||
...buildAuthUser(session.user),
|
||||
company: userRow?.company ?? undefined,
|
||||
// Si el nombre fue editado en la app (no solo el de Google), usar el de la tabla:
|
||||
name: userRow?.name ?? buildAuthUser(session.user).name,
|
||||
}
|
||||
```
|
||||
|
||||
**Paso 4 — UI en AppHeader: mostrar empresa**
|
||||
|
||||
En el dropdown del AppHeader, agregar la empresa debajo del email si está definida:
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col px-3 py-2 text-sm">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||
{user.company && (
|
||||
<span className="text-muted-foreground text-xs mt-0.5">{user.company}</span>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Paso 5 — Edición de perfil:**
|
||||
|
||||
Agregar un item "Editar perfil" en el dropdown (antes de "Cerrar sesión"), que abre un `Sheet` (el mismo componente Sheet de shadcn/ui ya usado en `ResourceFormSheet`):
|
||||
|
||||
El Sheet de perfil tiene:
|
||||
- **Nombre** — input editable (texto libre)
|
||||
- **Empresa** — input editable (texto libre, placeholder "InQuality")
|
||||
- **Email** — solo lectura (viene de Google OAuth, no editable)
|
||||
- Botón "Guardar" — hace `UPDATE users SET name = ?, company = ? WHERE id = ?`
|
||||
- Botón "Cancelar"
|
||||
|
||||
Al guardar: actualizar el store de auth local para que el AppHeader refleje el cambio sin recargar.
|
||||
|
||||
```ts
|
||||
// En el repo o en el store, al guardar perfil:
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ name, company })
|
||||
.eq('id', user.id)
|
||||
|
||||
// Actualizar el AuthUser en el store local:
|
||||
updateUser({ name, company })
|
||||
```
|
||||
|
||||
`updateUser` debe existir en `AuthContext` o `useAuth` — si no existe, crearlo.
|
||||
|
||||
---
|
||||
|
||||
## Qué NO entra en esta etapa
|
||||
|
||||
- Cambios en la lógica de simulación o heatmap
|
||||
- Gestión de roles o permisos de usuario
|
||||
- Página de perfil separada (el Sheet del dropdown es suficiente)
|
||||
- Campos adicionales más allá de nombre y empresa
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecución
|
||||
|
||||
1. Migración SQL `006_add_company_to_users.sql` — ejecutar en Supabase + crear archivo
|
||||
2. Extender `AuthUser` con `company?` en `src/auth/types.ts`
|
||||
3. Modificar `buildAuthUser` / `onAuthStateChange` para leer `company` y `name` de tabla `users`
|
||||
4. AppHeader: mostrar empresa en dropdown si existe
|
||||
5. AppHeader: agregar "Editar perfil" → Sheet con nombre/empresa editables
|
||||
6. `resource-repo.ts`: agregar `getProcessCountsPerResource()` y `getProcessesForResource()`
|
||||
7. `ResourceCatalogPage`: cargar conteos en paralelo con recursos, agregar columna "Procesos"
|
||||
8. `ResourceCatalogPage`: expansión inline de fila con lista de procesos linkeados
|
||||
9. `npm run build` — 0 errores TypeScript
|
||||
10. `npm run lint` — 0 warnings nuevos
|
||||
11. `npm run test` — mantener 528+ tests Vitest verdes
|
||||
12. Commit: `feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil`
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] Columna "Procesos" visible en `/recursos` con conteo correcto (verificar con un recurso usado en ≥1 proceso)
|
||||
- [ ] Recurso sin asignaciones muestra "—" en la columna Procesos
|
||||
- [ ] Click en el badge expande la lista de procesos con nombres y links funcionales
|
||||
- [ ] Click en un proceso navega al workspace correcto
|
||||
- [ ] Migración SQL ejecutada sin errores; `SELECT company FROM users` no falla
|
||||
- [ ] AppHeader muestra empresa si está definida (probar editando desde el Sheet)
|
||||
- [ ] Sheet de perfil: guardar nombre y empresa persiste en Supabase y se refleja en el dropdown
|
||||
- [ ] Email en el Sheet es solo lectura
|
||||
- [ ] 528+ tests Vitest verdes
|
||||
- [ ] **Validación visual del director** antes del OK formal
|
||||
@@ -7,6 +7,7 @@ interface AuthContextValue {
|
||||
loading: boolean
|
||||
signIn: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
@@ -78,8 +79,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
|
||||
setUser((prev) => prev ? { ...prev, ...updates } : prev)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, signIn, signOut }}>
|
||||
<AuthContext.Provider value={{ user, loading, signIn, signOut, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AuthUser {
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
platformRole: 'platform_admin' | 'member' | 'guest'
|
||||
company?: string
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
|
||||
@@ -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 (
|
||||
<header
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src="/inquality-logo-white.png"
|
||||
alt="InQuality"
|
||||
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
|
||||
/>
|
||||
</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
|
||||
<>
|
||||
<header
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src="/inquality-logo-white.png"
|
||||
alt="InQuality"
|
||||
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
{user.company && (
|
||||
<p className="text-xs text-muted-foreground leading-tight mt-0.5">{user.company}</p>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setProfileOpen(true)}>
|
||||
<UserPen className="h-3.5 w-3.5" />
|
||||
Editar perfil
|
||||
</DropdownMenuItem>
|
||||
<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>
|
||||
|
||||
<ProfileSheet open={profileOpen} onOpenChange={setProfileOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
91
src/components/ProfileSheet.tsx
Normal file
91
src/components/ProfileSheet.tsx
Normal file
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Editar perfil</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nombre</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Tu nombre"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Empresa</Label>
|
||||
<Input
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="InQuality"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Email</Label>
|
||||
<Input value={user?.email ?? ''} readOnly className="bg-muted text-muted-foreground cursor-not-allowed" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="mt-6 flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim() || saving} className="gap-1.5">
|
||||
<Check className="h-4 w-4" />
|
||||
Guardar
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [resources, setResources] = useState<ResourceWithCreator[]>([])
|
||||
const [processCounts, setProcessCounts] = useState<Record<string, number>>({})
|
||||
const [expandedResourceId, setExpandedResourceId] = useState<string | null>(null)
|
||||
const [expandedProcesses, setExpandedProcesses] = useState<Record<string, { id: string; name: string }[]>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<ResourceType | 'all'>('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() {
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Costo/h</TableHead>
|
||||
<TableHead>Creado por</TableHead>
|
||||
<TableHead>Procesos</TableHead>
|
||||
<TableHead className="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((resource) => (
|
||||
<TableRow key={resource.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ResourceTypeAvatar type={resource.type} size={28} />
|
||||
<span className="text-[13px] font-medium">{resource.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{
|
||||
background: RESOURCE_TYPE_STYLES[resource.type].bg,
|
||||
color: RESOURCE_TYPE_STYLES[resource.type].fg,
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{confirmingDeleteId === resource.id ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">¿Eliminar?</span>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-[11px]" onClick={() => handleDelete(resource.id)}>
|
||||
Sí
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => setConfirmingDeleteId(null)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{fromProcess && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={backToProcess}>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 text-xs">
|
||||
Volver al workspace para asignar este recurso a una actividad
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{filtered.map((resource) => {
|
||||
const count = processCounts[resource.id] ?? 0
|
||||
const isExpanded = expandedResourceId === resource.id
|
||||
return (
|
||||
<>
|
||||
<TableRow key={resource.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ResourceTypeAvatar type={resource.type} size={28} />
|
||||
<span className="text-[13px] font-medium">{resource.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{
|
||||
background: RESOURCE_TYPE_STYLES[resource.type].bg,
|
||||
color: RESOURCE_TYPE_STYLES[resource.type].fg,
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
|
||||
<TableCell>
|
||||
{count > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(resource.id)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-md px-2 py-0.5 transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
{count} {count === 1 ? 'proceso' : 'procesos'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-[13px]">—</span>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => setConfirmingDeleteId(resource.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{confirmingDeleteId === resource.id ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">¿Eliminar?</span>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-[11px]" onClick={() => handleDelete(resource.id)}>
|
||||
Sí
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => setConfirmingDeleteId(null)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{fromProcess && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={backToProcess}>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 text-xs">
|
||||
Volver al workspace para asignar este recurso a una actividad
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => setConfirmingDeleteId(resource.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{isExpanded && (
|
||||
<TableRow key={`${resource.id}-processes`} className="bg-orange-50/40 hover:bg-orange-50/40">
|
||||
<TableCell colSpan={6} className="px-4 pb-3 pt-1">
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
|
||||
Procesos que usan este recurso
|
||||
</p>
|
||||
{expandedProcesses[resource.id] ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{expandedProcesses[resource.id].map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to="/workspace/$processId"
|
||||
params={{ processId: p.id }}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
))}
|
||||
{expandedProcesses[resource.id].length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Sin procesos encontrados</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Cargando...</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
@@ -56,6 +56,48 @@ export const supabaseResourceRepo = {
|
||||
return (data ?? []).map((row) => fromRowWithCreator(row as Record<string, unknown>))
|
||||
},
|
||||
|
||||
// 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<Record<string, number>> {
|
||||
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<string, Set<string>> = {}
|
||||
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<string>()
|
||||
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<void> {
|
||||
const { count, error: countError } = await supabase
|
||||
|
||||
3
supabase/migrations/013_add_company_to_users.sql
Normal file
3
supabase/migrations/013_add_company_to_users.sql
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user