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()
// 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(), [])

View File

@@ -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',
}
}
}

View File

@@ -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

View File

@@ -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({

View File

@@ -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>
)

View File

@@ -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')

View File

@@ -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>
)
}

View File

@@ -71,7 +71,6 @@ export function ResourcesPanel() {
} else {
const newResource: Resource = {
id: uuidv4(),
processId: currentProcess.id,
name: form.name.trim(),
type: form.type,
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> {
// 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()
})
},

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 />
}

View File

@@ -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) {

View File

@@ -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() }

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);