Corrección 1 — Delete de proceso restaurado +

This commit is contained in:
2026-05-29 18:45:48 -03:00
parent 0a3c60cd09
commit 2955399ade
19 changed files with 502 additions and 98 deletions

View File

@@ -13,29 +13,45 @@ const AuthContext = createContext<AuthContextValue | null>(null)
const authService = new SupabaseAuthService() const authService = new SupabaseAuthService()
// Lee el usuario almacenado en localStorage SIN hacer llamadas de red.
// Supabase v2 usa la clave sb-{project_ref}-auth-token.
// Permite renderizar la app inmediatamente sin esperar la verificación async de sesión.
function getUserFromStorage(): AuthUser | null {
try {
const key = Object.keys(localStorage).find(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
)
if (!key) return null
const stored = JSON.parse(localStorage.getItem(key) ?? 'null')
const u = stored?.user
if (!u?.id) return null
const email = (u.email as string) ?? ''
return {
id: u.id as string,
email,
name: (u.user_metadata?.full_name as string) ?? email,
avatarUrl: u.user_metadata?.avatar_url as string | undefined,
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
}
} catch {
return null
}
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null) // Inicialización sincrónica desde localStorage — 0ms, sin red
const [loading, setLoading] = useState(true) const [user, setUser] = useState<AuthUser | null>(getUserFromStorage())
// loading siempre false: la respuesta de localStorage es inmediata
const [loading] = useState(false)
useEffect(() => { useEffect(() => {
// Timeout de seguridad: si Supabase no responde en 10s, desbloquear UI // Verificar y sincronizar con Supabase en background
const timeout = setTimeout(() => { // Si la sesión expiró o fue revocada, onAuthStateChange llamará callback(null)
setLoading((prev) => { // y el router redirigirá a /login
if (prev) console.error('Auth timeout: la sesión de Supabase no respondió en 10s')
return false
})
}, 10_000)
const unsubscribe = authService.onAuthStateChange((u) => { const unsubscribe = authService.onAuthStateChange((u) => {
clearTimeout(timeout)
setUser(u) setUser(u)
setLoading(false)
}) })
return () => unsubscribe()
return () => {
clearTimeout(timeout)
unsubscribe()
}
}, []) }, [])
const signIn = useCallback(() => authService.signInWithGoogle(), []) const signIn = useCallback(() => authService.signInWithGoogle(), [])

View File

@@ -24,14 +24,19 @@ export class SupabaseAuthService implements AuthService {
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void { onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => { async (event, session) => {
// Siempre llamar callback — incluso si falla algo interno
try { try {
if (!session?.user) { if (!session?.user) {
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
// Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc).
if (event !== 'INITIAL_SESSION') {
callback(null) callback(null)
}
return return
} }
if (event === 'SIGNED_IN') { if (event === 'SIGNED_IN') {
// Primer login: upsert del usuario en la tabla users
const email = session.user.email ?? '' const email = session.user.email ?? ''
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest' const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
const { error: upsertError } = await supabase.from('users').upsert( const { error: upsertError } = await supabase.from('users').upsert(
@@ -46,19 +51,14 @@ 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)
} }
const user = await this.buildAuthUser(session.user) // buildAuthUser es sincrónico — sin DB query, usa metadatos de la sesión
const user = this.buildAuthUser(session.user)
callback(user) callback(user)
} catch (err) { } catch (err) {
console.error('Error en onAuthStateChange:', err) console.error('Error en onAuthStateChange:', err)
// Garantizar que loading se desbloquea incluso en error
if (session?.user) { if (session?.user) {
callback({ callback(this.buildAuthUser(session.user))
id: session.user.id, } else if (event !== 'INITIAL_SESSION') {
email: session.user.email ?? '',
name: session.user.user_metadata?.full_name as string ?? session.user.email ?? '',
platformRole: 'guest',
})
} else {
callback(null) callback(null)
} }
} }
@@ -68,20 +68,16 @@ export class SupabaseAuthService implements AuthService {
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
} }
private async buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): Promise<AuthUser> { // Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
// El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN).
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
const email = supabaseUser.email ?? '' const email = supabaseUser.email ?? ''
const { data: row } = await supabase
.from('users')
.select('name, avatar_url, platform_role')
.eq('id', supabaseUser.id)
.single()
return { return {
id: supabaseUser.id, id: supabaseUser.id,
email, email,
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email, name: supabaseUser.user_metadata?.full_name as string ?? email,
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined, avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'], platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
} }
} }
} }

View File

@@ -63,7 +63,7 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
export interface Resource { export interface Resource {
id: UUID id: UUID
processId: UUID processId?: UUID // opcional — recursos son catálogo global desde Etapa 3
name: string name: string
type: ResourceType type: ResourceType
costPerHour: number costPerHour: number

View File

@@ -4,8 +4,9 @@ import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser' import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
import { activityRepo, gatewayRepo } from '@/persistence/repositories'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
import type { Process, Activity, GatewayConfig } from '@/domain/types' import type { Process, Activity, GatewayConfig } from '@/domain/types'
import { bpmnTypeToGatewayType } from '@/domain/types' import { bpmnTypeToGatewayType } from '@/domain/types'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
@@ -114,10 +115,20 @@ export function ImportPage() {
} }
}) })
await Promise.race([
(async () => {
await supabaseProcessRepo.save(process, user!.id)
await Promise.all([ await Promise.all([
supabaseProcessRepo.save(process, user!.id), supabaseActivityRepo.saveMany(activities, user!.id),
activityRepo.saveMany(activities), supabaseGatewayRepo.saveMany(gateways),
gatewayRepo.saveMany(gateways), ])
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Timeout: Supabase no respondió en 20s — verificá tu conexión')),
20_000,
)
),
]) ])
toast({ toast({

View File

@@ -312,7 +312,7 @@ function HomeView({
onNavigateToGroup: (groupId: string | null) => void onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void onImport: (groupId?: string) => void
}) { }) {
const { groups, processes, settings, createGroup } = useLibraryStore() const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
const { user } = useAuth() const { user } = useAuth()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([]) const [activeTags, setActiveTags] = useState<string[]>([])
@@ -330,7 +330,26 @@ function HomeView({
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]) setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
} }
// Empty state: sin grupos ni procesos // Skeleton mientras carga y aún no hay datos (evita flash de empty state)
if (isLoading && groups.length === 0 && processes.length === 0) {
return (
<div className="space-y-5 animate-pulse">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-xl bg-slate-100" />
))}
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-14 rounded-xl bg-slate-100" />
))}
</div>
</div>
)
}
// Empty state: sin grupos ni procesos (ya terminó de cargar)
if (groups.length === 0 && processes.length === 0) { if (groups.length === 0 && processes.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-24 gap-4"> <div className="flex flex-col items-center justify-center py-24 gap-4">
@@ -601,7 +620,7 @@ function GroupView({
export function LibraryPage() { export function LibraryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { load, isLoading, processes, groups, settings } = useLibraryStore() const { load, processes, groups, settings } = useLibraryStore()
const [view, setView] = useState<'home' | 'group'>('home') const [view, setView] = useState<'home' | 'group'>('home')
const [activeGroupId, setActiveGroupId] = useState<string | null>(null) const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
const [transitioning, setTransitioning] = useState(false) const [transitioning, setTransitioning] = useState(false)
@@ -670,12 +689,7 @@ export function LibraryPage() {
</div> </div>
</div> </div>
{/* Contenido con transición */} {/* Contenido — sin spinner bloqueante: skeleton dentro de HomeView */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : (
<div <div
className="transition-all duration-150" className="transition-all duration-150"
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }} style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
@@ -692,7 +706,6 @@ export function LibraryPage() {
/> />
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories' import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { simulationRepo } from '@/persistence/repositories'
import type { Process, Activity, Resource, Simulation } from '@/domain/types' import type { Process, Activity, Resource, Simulation } from '@/domain/types'
interface ReportData { interface ReportData {
@@ -30,8 +32,8 @@ export function useReportData(processId: string): ReportData {
const [proc, sim, acts, res] = await Promise.all([ const [proc, sim, acts, res] = await Promise.all([
supabaseProcessRepo.getById(processId), supabaseProcessRepo.getById(processId),
simulationRepo.getLatestByProcess(processId), simulationRepo.getLatestByProcess(processId),
activityRepo.getByProcess(processId), supabaseActivityRepo.getByProcess(processId),
resourceRepo.getByProcess(processId), supabaseResourceRepo.getAll(),
]) ])
if (cancelled) return if (cancelled) return
if (!proc) throw new Error('Proceso no encontrado en la base de datos local') if (!proc) throw new Error('Proceso no encontrado en la base de datos local')

View File

@@ -136,8 +136,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
if (!localActivity) { if (!localActivity) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full gap-2 px-6 text-center">
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p> <p className="text-xs text-slate-400">Actividad no encontrada en la base de datos.</p>
<p className="text-[11px] text-slate-300">
Si importaste este proceso antes de la última actualización, volvé a importar el archivo BPMN.
</p>
</div> </div>
) )
} }

View File

@@ -71,7 +71,6 @@ export function ResourcesPanel() {
} else { } else {
const newResource: Resource = { const newResource: Resource = {
id: uuidv4(), id: uuidv4(),
processId: currentProcess.id,
name: form.name.trim(), name: form.name.trim(),
type: form.type, type: form.type,
costPerHour, costPerHour,

View File

@@ -0,0 +1,183 @@
import { supabase } from '@/lib/supabase'
import type { Activity, ActivityResourceAssignment } from '@/domain/types'
function fromRow(
row: Record<string, unknown>,
assignedResources: ActivityResourceAssignment[],
automatedAssignedResources: ActivityResourceAssignment[],
): Activity {
return {
id: row.id as string,
processId: row.process_id as string,
bpmnElementId: row.bpmn_element_id as string,
name: (row.name as string) ?? '',
type: (row.type as Activity['type']) ?? 'task',
directCostFixed: Number(row.direct_cost_fixed ?? 0),
executionTimeMinutes: Number(row.execution_time_minutes ?? 0),
automatable: Boolean(row.automatable),
automatedCostFixed: Number(row.automated_cost_fixed ?? 0),
automatedTimeMinutes: Number(row.automated_time_minutes ?? 0),
assignedResources,
automatedAssignedResources,
}
}
function assignmentFromRow(row: Record<string, unknown>): ActivityResourceAssignment {
return {
resourceId: row.resource_id as string,
utilizationMinutes: Number(row.utilization_minutes ?? 0),
units: Number(row.units ?? 1),
}
}
// Extrae assignments de la relación anidada que Supabase devuelve con select('*, activity_resource_assignments(*)')
function extractAssignments(row: Record<string, unknown>): {
actual: ActivityResourceAssignment[]
automated: ActivityResourceAssignment[]
} {
const raw = (row.activity_resource_assignments as Array<Record<string, unknown>>) ?? []
return {
actual: raw.filter((a) => a.scenario === 'actual').map(assignmentFromRow),
automated: raw.filter((a) => a.scenario === 'automated').map(assignmentFromRow),
}
}
export const supabaseActivityRepo = {
async save(activity: Activity, userId: string): Promise<void> {
const { error: actError } = await supabase.from('activities').upsert({
id: activity.id,
process_id: activity.processId,
bpmn_element_id: activity.bpmnElementId,
name: activity.name,
type: activity.type,
direct_cost_fixed: activity.directCostFixed,
execution_time_minutes: activity.executionTimeMinutes,
automatable: activity.automatable,
automated_cost_fixed: activity.automatedCostFixed,
automated_time_minutes: activity.automatedTimeMinutes,
updated_by: userId,
updated_at: new Date().toISOString(),
})
if (actError) throw actError
const { error: delError } = await supabase
.from('activity_resource_assignments')
.delete()
.eq('activity_id', activity.id)
if (delError) throw delError
const assignments = [
...activity.assignedResources.map((r) => ({
activity_id: activity.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'actual',
})),
...(activity.automatedAssignedResources ?? []).map((r) => ({
activity_id: activity.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'automated',
})),
]
if (assignments.length > 0) {
const { error: insError } = await supabase
.from('activity_resource_assignments')
.insert(assignments)
if (insError) throw insError
}
},
// Bulk insert optimizado: 3 round-trips totales sin importar el número de actividades
async saveMany(activities: Activity[], userId: string): Promise<void> {
if (activities.length === 0) return
const now = new Date().toISOString()
const { error: actError } = await supabase.from('activities').upsert(
activities.map((a) => ({
id: a.id,
process_id: a.processId,
bpmn_element_id: a.bpmnElementId,
name: a.name,
type: a.type,
direct_cost_fixed: a.directCostFixed,
execution_time_minutes: a.executionTimeMinutes,
automatable: a.automatable,
automated_cost_fixed: a.automatedCostFixed,
automated_time_minutes: a.automatedTimeMinutes,
updated_by: userId,
updated_at: now,
}))
)
if (actError) throw actError
const activityIds = activities.map((a) => a.id)
const { error: delError } = await supabase
.from('activity_resource_assignments')
.delete()
.in('activity_id', activityIds)
if (delError) throw delError
const assignments = activities.flatMap((a) => [
...a.assignedResources.map((r) => ({
activity_id: a.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'actual',
})),
...(a.automatedAssignedResources ?? []).map((r) => ({
activity_id: a.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'automated',
})),
])
if (assignments.length > 0) {
const { error: insError } = await supabase
.from('activity_resource_assignments')
.insert(assignments)
if (insError) throw insError
}
},
// Un solo round-trip: activities + assignments anidados en una query
async getByProcess(processId: string): Promise<Activity[]> {
const { data, error } = await supabase
.from('activities')
.select('*, activity_resource_assignments(*)')
.eq('process_id', processId)
if (error) throw error
return (data ?? []).map((row) => {
const { actual, automated } = extractAssignments(row as Record<string, unknown>)
return fromRow(row as Record<string, unknown>, actual, automated)
})
},
// Un solo round-trip: actividad + assignments anidados
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
const { data, error } = await supabase
.from('activities')
.select('*, activity_resource_assignments(*)')
.eq('process_id', processId)
.eq('bpmn_element_id', bpmnElementId)
.single()
if (error) return undefined
const { actual, automated } = extractAssignments(data as Record<string, unknown>)
return fromRow(data as Record<string, unknown>, actual, automated)
},
async deleteByProcess(processId: string): Promise<void> {
// CASCADE elimina activity_resource_assignments automáticamente
const { error } = await supabase.from('activities').delete().eq('process_id', processId)
if (error) throw error
},
}

View File

@@ -0,0 +1,55 @@
import { supabase } from '@/lib/supabase'
import type { GatewayConfig } from '@/domain/types'
function fromRow(row: Record<string, unknown>): GatewayConfig {
return {
id: row.id as string,
processId: row.process_id as string,
bpmnElementId: row.bpmn_element_id as string,
gatewayType: row.gateway_type as GatewayConfig['gatewayType'],
branches: Array.isArray(row.branches) ? row.branches as GatewayConfig['branches'] : [],
}
}
export const supabaseGatewayRepo = {
async save(gateway: GatewayConfig): Promise<void> {
const { error } = await supabase.from('gateways').upsert({
id: gateway.id,
process_id: gateway.processId,
bpmn_element_id: gateway.bpmnElementId,
gateway_type: gateway.gatewayType,
branches: gateway.branches,
updated_at: new Date().toISOString(),
})
if (error) throw error
},
async saveMany(gateways: GatewayConfig[]): Promise<void> {
if (gateways.length === 0) return
const { error } = await supabase.from('gateways').upsert(
gateways.map((g) => ({
id: g.id,
process_id: g.processId,
bpmn_element_id: g.bpmnElementId,
gateway_type: g.gatewayType,
branches: g.branches,
updated_at: new Date().toISOString(),
}))
)
if (error) throw error
},
async getByProcess(processId: string): Promise<GatewayConfig[]> {
const { data, error } = await supabase
.from('gateways')
.select('*')
.eq('process_id', processId)
if (error) throw error
return (data ?? []).map(fromRow)
},
async deleteByProcess(processId: string): Promise<void> {
const { error } = await supabase.from('gateways').delete().eq('process_id', processId)
if (error) throw error
},
}

View File

@@ -66,14 +66,12 @@ export const supabaseProcessRepo = {
}, },
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments
const { error } = await supabase.from('processes').delete().eq('id', id) const { error } = await supabase.from('processes').delete().eq('id', id)
if (error) throw error if (error) throw error
// TODO Sprint 4 Etapa 3-4: eliminar limpieza Dexie cuando actividades migren a Supabase // TODO Sprint 4 Etapa 4: eliminar cuando simulaciones migren a Supabase
await db.transaction('rw', [db.activities, db.gateways, db.resources, db.simulations], async () => { await db.transaction('rw', [db.simulations], async () => {
await db.activities.where('processId').equals(id).delete()
await db.gateways.where('processId').equals(id).delete()
await db.resources.where('processId').equals(id).delete()
await db.simulations.where('processId').equals(id).delete() await db.simulations.where('processId').equals(id).delete()
}) })
}, },

View File

@@ -0,0 +1,40 @@
import { supabase } from '@/lib/supabase'
import type { Resource } from '@/domain/types'
function fromRow(row: Record<string, unknown>): Resource {
return {
id: row.id as string,
name: row.name as string,
type: row.type as Resource['type'],
costPerHour: Number(row.cost_per_hour ?? 0),
}
}
export const supabaseResourceRepo = {
async save(resource: Resource, userId: string): Promise<void> {
const { error } = await supabase.from('resources').upsert({
id: resource.id,
name: resource.name,
type: resource.type,
cost_per_hour: resource.costPerHour,
created_by: userId,
updated_by: userId,
updated_at: new Date().toISOString(),
})
if (error) throw error
},
async getAll(): Promise<Resource[]> {
const { data, error } = await supabase
.from('resources')
.select('*')
.order('name', { ascending: true })
if (error) throw error
return (data ?? []).map(fromRow)
},
async delete(resourceId: string): Promise<void> {
const { error } = await supabase.from('resources').delete().eq('id', resourceId)
if (error) throw error
},
}

View File

@@ -47,7 +47,11 @@ function RootComponent() {
) )
} }
if (!user && pathname !== '/login') return null if (!user && pathname !== '/login') return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
</div>
)
return <Outlet /> return <Outlet />
} }

View File

@@ -39,10 +39,19 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
load: async () => { load: async () => {
set({ isLoading: true }) set({ isLoading: true })
try { try {
const [groups, processes, settings] = await Promise.all([ const TIMEOUT_MS = 8_000
const [groups, processes, settings] = await Promise.race([
Promise.all([
supabaseGroupRepo.getAll(), supabaseGroupRepo.getAll(),
supabaseProcessRepo.getAll(), supabaseProcessRepo.getAll(),
supabaseSettingsRepo.get(), supabaseSettingsRepo.get(),
]),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Timeout: la biblioteca tardó más de 8s en cargar')),
TIMEOUT_MS,
)
),
]) ])
set({ groups, processes, settings, isLoading: false }) set({ groups, processes, settings, isLoading: false })
} catch (err) { } catch (err) {

View File

@@ -1,6 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories' import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types' import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types'
interface ProcessState { interface ProcessState {
@@ -35,9 +37,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
try { try {
const [process, activities, gateways, resources] = await Promise.all([ const [process, activities, gateways, resources] = await Promise.all([
supabaseProcessRepo.getById(processId), supabaseProcessRepo.getById(processId),
activityRepo.getByProcess(processId), supabaseActivityRepo.getByProcess(processId),
gatewayRepo.getByProcess(processId), supabaseGatewayRepo.getByProcess(processId),
resourceRepo.getByProcess(processId), supabaseResourceRepo.getAll(),
]) ])
if (!process) throw new Error('Proceso no encontrado') if (!process) throw new Error('Proceso no encontrado')
set({ currentProcess: process, activities, gateways, resources, isLoading: false }) set({ currentProcess: process, activities, gateways, resources, isLoading: false })
@@ -49,9 +51,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
setCurrentProcess: async (process, userId) => { setCurrentProcess: async (process, userId) => {
await supabaseProcessRepo.save(process, userId) await supabaseProcessRepo.save(process, userId)
const [activities, gateways, resources] = await Promise.all([ const [activities, gateways, resources] = await Promise.all([
activityRepo.getByProcess(process.id), supabaseActivityRepo.getByProcess(process.id),
gatewayRepo.getByProcess(process.id), supabaseGatewayRepo.getByProcess(process.id),
resourceRepo.getByProcess(process.id), supabaseResourceRepo.getAll(),
]) ])
set({ currentProcess: process, activities, gateways, resources }) set({ currentProcess: process, activities, gateways, resources })
}, },
@@ -65,7 +67,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}, },
updateActivity: async (activity, userId) => { updateActivity: async (activity, userId) => {
await activityRepo.save(activity) await supabaseActivityRepo.save(activity, userId)
const current = get().currentProcess const current = get().currentProcess
if (current) { if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() } const updatedProcess = { ...current, updatedAt: Date.now() }
@@ -82,7 +84,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}, },
updateGateway: async (gateway, userId) => { updateGateway: async (gateway, userId) => {
await gatewayRepo.save(gateway) await supabaseGatewayRepo.save(gateway)
const current = get().currentProcess const current = get().currentProcess
if (current) { if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() } const updatedProcess = { ...current, updatedAt: Date.now() }
@@ -99,7 +101,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}, },
addResource: async (resource, userId) => { addResource: async (resource, userId) => {
await resourceRepo.save(resource) await supabaseResourceRepo.save(resource, userId)
const current = get().currentProcess const current = get().currentProcess
if (current) { if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() } const updatedProcess = { ...current, updatedAt: Date.now() }
@@ -111,7 +113,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}, },
updateResource: async (resource, userId) => { updateResource: async (resource, userId) => {
await resourceRepo.save(resource) await supabaseResourceRepo.save(resource, userId)
const current = get().currentProcess const current = get().currentProcess
if (current) { if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() } const updatedProcess = { ...current, updatedAt: Date.now() }
@@ -128,7 +130,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}, },
deleteResource: async (resourceId, userId) => { deleteResource: async (resourceId, userId) => {
await resourceRepo.delete(resourceId) await supabaseResourceRepo.delete(resourceId)
const current = get().currentProcess const current = get().currentProcess
if (current) { if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() } const updatedProcess = { ...current, updatedAt: Date.now() }

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS public.resources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
type text NOT NULL CHECK (type IN ('role','person','system','equipment','supply')),
cost_per_hour numeric NOT NULL DEFAULT 0,
created_by uuid REFERENCES public.users(id),
updated_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.resources ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_resources" ON public.resources
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS public.activities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
bpmn_element_id text NOT NULL,
name text NOT NULL DEFAULT '',
type text NOT NULL DEFAULT 'task' CHECK (type IN ('task','subprocess')),
direct_cost_fixed numeric NOT NULL DEFAULT 0,
execution_time_minutes numeric NOT NULL DEFAULT 0,
automatable boolean NOT NULL DEFAULT false,
automated_cost_fixed numeric NOT NULL DEFAULT 0,
automated_time_minutes numeric NOT NULL DEFAULT 0,
created_by uuid REFERENCES public.users(id),
updated_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS activities_process_bpmn_idx
ON public.activities(process_id, bpmn_element_id);
ALTER TABLE public.activities ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_activities" ON public.activities
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS public.activity_resource_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id uuid NOT NULL REFERENCES public.activities(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES public.resources(id) ON DELETE CASCADE,
utilization_minutes numeric NOT NULL DEFAULT 0,
units numeric NOT NULL DEFAULT 1,
scenario text NOT NULL DEFAULT 'actual' CHECK (scenario IN ('actual','automated'))
);
ALTER TABLE public.activity_resource_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_assignments" ON public.activity_resource_assignments
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS public.gateways (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
bpmn_element_id text NOT NULL,
gateway_type text NOT NULL CHECK (gateway_type IN ('exclusive','parallel','inclusive','event-based')),
branches jsonb NOT NULL DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS gateways_process_bpmn_idx
ON public.gateways(process_id, bpmn_element_id);
ALTER TABLE public.gateways ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_gateways" ON public.gateways
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);