feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil
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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ interface AuthContextValue {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
signIn: () => Promise<void>
|
signIn: () => Promise<void>
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
|
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -78,8 +79,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(null)
|
setUser(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
|
||||||
|
setUser((prev) => prev ? { ...prev, ...updates } : prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, signIn, signOut }}>
|
<AuthContext.Provider value={{ user, loading, signIn, signOut, updateUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,8 +60,19 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
|
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAuthUser es sincrónico — sin DB query, usa metadatos de la sesión
|
// Leer company y name editado desde la tabla users (pueden diferir del user_metadata de Google)
|
||||||
const user = this.buildAuthUser(session.user)
|
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)
|
callback(user)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error en onAuthStateChange:', err)
|
console.error('Error en onAuthStateChange:', err)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface AuthUser {
|
|||||||
name: string
|
name: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
platformRole: 'platform_admin' | 'member' | 'guest'
|
platformRole: 'platform_admin' | 'member' | 'guest'
|
||||||
|
company?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthService {
|
export interface AuthService {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { LogOut } from 'lucide-react'
|
import { LogOut, UserPen } from 'lucide-react'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { ProfileSheet } from './ProfileSheet'
|
||||||
|
|
||||||
function initials(name: string): string {
|
function initials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/)
|
const parts = name.trim().split(/\s+/)
|
||||||
@@ -38,8 +40,10 @@ function UserAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
|
|||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { user, signOut } = useAuth()
|
const { user, signOut } = useAuth()
|
||||||
|
const [profileOpen, setProfileOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
|
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
|
||||||
@@ -84,8 +88,16 @@ export function AppHeader() {
|
|||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
<p className="text-sm font-medium leading-tight">{user.name}</p>
|
<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>
|
<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>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<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">
|
<DropdownMenuItem onClick={() => signOut()} className="text-destructive focus:text-destructive">
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
Cerrar sesión
|
Cerrar sesión
|
||||||
@@ -95,5 +107,8 @@ export function AppHeader() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
import { useNavigate, useSearch, Link } from '@tanstack/react-router'
|
||||||
import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft } from 'lucide-react'
|
import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -24,6 +24,9 @@ export function ResourceCatalogPage() {
|
|||||||
const fromProcess = (search_ as { fromProcess?: string }).fromProcess
|
const fromProcess = (search_ as { fromProcess?: string }).fromProcess
|
||||||
const [fromProcessName, setFromProcessName] = useState<string | null>(null)
|
const [fromProcessName, setFromProcessName] = useState<string | null>(null)
|
||||||
const [resources, setResources] = useState<ResourceWithCreator[]>([])
|
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 [isLoading, setIsLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [typeFilter, setTypeFilter] = useState<ResourceType | 'all'>('all')
|
const [typeFilter, setTypeFilter] = useState<ResourceType | 'all'>('all')
|
||||||
@@ -44,8 +47,12 @@ export function ResourceCatalogPage() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await supabaseResourceRepo.getAllWithCreator()
|
const [data, counts] = await Promise.all([
|
||||||
|
supabaseResourceRepo.getAllWithCreator(),
|
||||||
|
supabaseResourceRepo.getProcessCountsPerResource(),
|
||||||
|
])
|
||||||
setResources(data)
|
setResources(data)
|
||||||
|
setProcessCounts(counts)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -55,6 +62,18 @@ export function ResourceCatalogPage() {
|
|||||||
load()
|
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 filtered = resources.filter((r) => {
|
||||||
const matchesName = r.name.toLowerCase().includes(search.toLowerCase())
|
const matchesName = r.name.toLowerCase().includes(search.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || r.type === typeFilter
|
const matchesType = typeFilter === 'all' || r.type === typeFilter
|
||||||
@@ -182,11 +201,16 @@ export function ResourceCatalogPage() {
|
|||||||
<TableHead>Tipo</TableHead>
|
<TableHead>Tipo</TableHead>
|
||||||
<TableHead>Costo/h</TableHead>
|
<TableHead>Costo/h</TableHead>
|
||||||
<TableHead>Creado por</TableHead>
|
<TableHead>Creado por</TableHead>
|
||||||
|
<TableHead>Procesos</TableHead>
|
||||||
<TableHead className="text-right">Acciones</TableHead>
|
<TableHead className="text-right">Acciones</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((resource) => (
|
{filtered.map((resource) => {
|
||||||
|
const count = processCounts[resource.id] ?? 0
|
||||||
|
const isExpanded = expandedResourceId === resource.id
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<TableRow key={resource.id}>
|
<TableRow key={resource.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -208,6 +232,20 @@ export function ResourceCatalogPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
|
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</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>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{confirmingDeleteId === resource.id ? (
|
{confirmingDeleteId === resource.id ? (
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
@@ -243,7 +281,38 @@ export function ResourceCatalogPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,6 +56,48 @@ export const supabaseResourceRepo = {
|
|||||||
return (data ?? []).map((row) => fromRowWithCreator(row as Record<string, unknown>))
|
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
|
// No elimina si el recurso tiene asignaciones activas en activity_resource_assignments
|
||||||
async delete(resourceId: string): Promise<void> {
|
async delete(resourceId: string): Promise<void> {
|
||||||
const { count, error: countError } = await supabase
|
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