Corrección 1 — Delete de proceso restaurado +
This commit is contained in:
@@ -13,29 +13,45 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
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 }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Inicialización sincrónica desde localStorage — 0ms, sin red
|
||||
const [user, setUser] = useState<AuthUser | null>(getUserFromStorage())
|
||||
// loading siempre false: la respuesta de localStorage es inmediata
|
||||
const [loading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Timeout de seguridad: si Supabase no responde en 10s, desbloquear UI
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading((prev) => {
|
||||
if (prev) console.error('Auth timeout: la sesión de Supabase no respondió en 10s')
|
||||
return false
|
||||
})
|
||||
}, 10_000)
|
||||
|
||||
// Verificar y sincronizar con Supabase en background
|
||||
// Si la sesión expiró o fue revocada, onAuthStateChange llamará callback(null)
|
||||
// y el router redirigirá a /login
|
||||
const unsubscribe = authService.onAuthStateChange((u) => {
|
||||
clearTimeout(timeout)
|
||||
setUser(u)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
}
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
|
||||
const signIn = useCallback(() => authService.signInWithGoogle(), [])
|
||||
|
||||
@@ -24,14 +24,19 @@ export class SupabaseAuthService implements AuthService {
|
||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
// Siempre llamar callback — incluso si falla algo interno
|
||||
try {
|
||||
if (!session?.user) {
|
||||
callback(null)
|
||||
// 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)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
// Primer login: upsert del usuario en la tabla users
|
||||
const email = session.user.email ?? ''
|
||||
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
} catch (err) {
|
||||
console.error('Error en onAuthStateChange:', err)
|
||||
// Garantizar que loading se desbloquea incluso en error
|
||||
if (session?.user) {
|
||||
callback({
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? '',
|
||||
name: session.user.user_metadata?.full_name as string ?? session.user.email ?? '',
|
||||
platformRole: 'guest',
|
||||
})
|
||||
} else {
|
||||
callback(this.buildAuthUser(session.user))
|
||||
} else if (event !== 'INITIAL_SESSION') {
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
@@ -68,20 +68,16 @@ export class SupabaseAuthService implements AuthService {
|
||||
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 { data: row } = await supabase
|
||||
.from('users')
|
||||
.select('name, avatar_url, platform_role')
|
||||
.eq('id', supabaseUser.id)
|
||||
.single()
|
||||
|
||||
return {
|
||||
id: supabaseUser.id,
|
||||
email,
|
||||
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email,
|
||||
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined,
|
||||
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'],
|
||||
name: supabaseUser.user_metadata?.full_name as string ?? email,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
|
||||
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
|
||||
|
||||
export interface Resource {
|
||||
id: UUID
|
||||
processId: UUID
|
||||
processId?: UUID // opcional — recursos son catálogo global desde Etapa 3
|
||||
name: string
|
||||
type: ResourceType
|
||||
costPerHour: number
|
||||
|
||||
@@ -4,8 +4,9 @@ import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
|
||||
import { activityRepo, gatewayRepo } from '@/persistence/repositories'
|
||||
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 { bpmnTypeToGatewayType } from '@/domain/types'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -114,10 +115,20 @@ export function ImportPage() {
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
supabaseProcessRepo.save(process, user!.id),
|
||||
activityRepo.saveMany(activities),
|
||||
gatewayRepo.saveMany(gateways),
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await supabaseProcessRepo.save(process, user!.id)
|
||||
await Promise.all([
|
||||
supabaseActivityRepo.saveMany(activities, user!.id),
|
||||
supabaseGatewayRepo.saveMany(gateways),
|
||||
])
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout: Supabase no respondió en 20s — verificá tu conexión')),
|
||||
20_000,
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
toast({
|
||||
|
||||
@@ -312,7 +312,7 @@ function HomeView({
|
||||
onNavigateToGroup: (groupId: string | null) => void
|
||||
onImport: (groupId?: string) => void
|
||||
}) {
|
||||
const { groups, processes, settings, createGroup } = useLibraryStore()
|
||||
const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
|
||||
const { user } = useAuth()
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
@@ -330,7 +330,26 @@ function HomeView({
|
||||
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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
@@ -601,7 +620,7 @@ function GroupView({
|
||||
|
||||
export function LibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { load, isLoading, processes, groups, settings } = useLibraryStore()
|
||||
const { load, processes, groups, settings } = useLibraryStore()
|
||||
const [view, setView] = useState<'home' | 'group'>('home')
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
@@ -670,29 +689,23 @@ export function LibraryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenido con transición */}
|
||||
{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
|
||||
className="transition-all duration-150"
|
||||
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
|
||||
>
|
||||
{view === 'home' ? (
|
||||
<HomeView
|
||||
onNavigateToGroup={navigateToGroup}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
) : (
|
||||
<GroupView
|
||||
groupId={activeGroupId}
|
||||
onBack={navigateHome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Contenido — sin spinner bloqueante: skeleton dentro de HomeView */}
|
||||
<div
|
||||
className="transition-all duration-150"
|
||||
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
|
||||
>
|
||||
{view === 'home' ? (
|
||||
<HomeView
|
||||
onNavigateToGroup={navigateToGroup}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
) : (
|
||||
<GroupView
|
||||
groupId={activeGroupId}
|
||||
onBack={navigateHome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { simulationRepo } from '@/persistence/repositories'
|
||||
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
|
||||
|
||||
interface ReportData {
|
||||
@@ -30,8 +32,8 @@ export function useReportData(processId: string): ReportData {
|
||||
const [proc, sim, acts, res] = await Promise.all([
|
||||
supabaseProcessRepo.getById(processId),
|
||||
simulationRepo.getLatestByProcess(processId),
|
||||
activityRepo.getByProcess(processId),
|
||||
resourceRepo.getByProcess(processId),
|
||||
supabaseActivityRepo.getByProcess(processId),
|
||||
supabaseResourceRepo.getAll(),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')
|
||||
|
||||
@@ -136,8 +136,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
|
||||
if (!localActivity) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p>
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 px-6 text-center">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export function ResourcesPanel() {
|
||||
} else {
|
||||
const newResource: Resource = {
|
||||
id: uuidv4(),
|
||||
processId: currentProcess.id,
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
costPerHour,
|
||||
|
||||
183
src/persistence/supabase/activity-repo.ts
Normal file
183
src/persistence/supabase/activity-repo.ts
Normal 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
|
||||
},
|
||||
}
|
||||
55
src/persistence/supabase/gateway-repo.ts
Normal file
55
src/persistence/supabase/gateway-repo.ts
Normal 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
|
||||
},
|
||||
}
|
||||
@@ -66,14 +66,12 @@ export const supabaseProcessRepo = {
|
||||
},
|
||||
|
||||
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)
|
||||
if (error) throw error
|
||||
|
||||
// TODO Sprint 4 Etapa 3-4: eliminar limpieza Dexie cuando actividades migren a Supabase
|
||||
await db.transaction('rw', [db.activities, db.gateways, db.resources, 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()
|
||||
// TODO Sprint 4 Etapa 4: eliminar cuando simulaciones migren a Supabase
|
||||
await db.transaction('rw', [db.simulations], async () => {
|
||||
await db.simulations.where('processId').equals(id).delete()
|
||||
})
|
||||
},
|
||||
|
||||
40
src/persistence/supabase/resource-repo.ts
Normal file
40
src/persistence/supabase/resource-repo.ts
Normal 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
|
||||
},
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -39,10 +39,19 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
load: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const [groups, processes, settings] = await Promise.all([
|
||||
supabaseGroupRepo.getAll(),
|
||||
supabaseProcessRepo.getAll(),
|
||||
supabaseSettingsRepo.get(),
|
||||
const TIMEOUT_MS = 8_000
|
||||
const [groups, processes, settings] = await Promise.race([
|
||||
Promise.all([
|
||||
supabaseGroupRepo.getAll(),
|
||||
supabaseProcessRepo.getAll(),
|
||||
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 })
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
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'
|
||||
|
||||
interface ProcessState {
|
||||
@@ -35,9 +37,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
try {
|
||||
const [process, activities, gateways, resources] = await Promise.all([
|
||||
supabaseProcessRepo.getById(processId),
|
||||
activityRepo.getByProcess(processId),
|
||||
gatewayRepo.getByProcess(processId),
|
||||
resourceRepo.getByProcess(processId),
|
||||
supabaseActivityRepo.getByProcess(processId),
|
||||
supabaseGatewayRepo.getByProcess(processId),
|
||||
supabaseResourceRepo.getAll(),
|
||||
])
|
||||
if (!process) throw new Error('Proceso no encontrado')
|
||||
set({ currentProcess: process, activities, gateways, resources, isLoading: false })
|
||||
@@ -49,9 +51,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
setCurrentProcess: async (process, userId) => {
|
||||
await supabaseProcessRepo.save(process, userId)
|
||||
const [activities, gateways, resources] = await Promise.all([
|
||||
activityRepo.getByProcess(process.id),
|
||||
gatewayRepo.getByProcess(process.id),
|
||||
resourceRepo.getByProcess(process.id),
|
||||
supabaseActivityRepo.getByProcess(process.id),
|
||||
supabaseGatewayRepo.getByProcess(process.id),
|
||||
supabaseResourceRepo.getAll(),
|
||||
])
|
||||
set({ currentProcess: process, activities, gateways, resources })
|
||||
},
|
||||
@@ -65,7 +67,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateActivity: async (activity, userId) => {
|
||||
await activityRepo.save(activity)
|
||||
await supabaseActivityRepo.save(activity, userId)
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
@@ -82,7 +84,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateGateway: async (gateway, userId) => {
|
||||
await gatewayRepo.save(gateway)
|
||||
await supabaseGatewayRepo.save(gateway)
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
@@ -99,7 +101,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
},
|
||||
|
||||
addResource: async (resource, userId) => {
|
||||
await resourceRepo.save(resource)
|
||||
await supabaseResourceRepo.save(resource, userId)
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
@@ -111,7 +113,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateResource: async (resource, userId) => {
|
||||
await resourceRepo.save(resource)
|
||||
await supabaseResourceRepo.save(resource, userId)
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
@@ -128,7 +130,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
},
|
||||
|
||||
deleteResource: async (resourceId, userId) => {
|
||||
await resourceRepo.delete(resourceId)
|
||||
await supabaseResourceRepo.delete(resourceId)
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
|
||||
Reference in New Issue
Block a user