diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index f023489..f348537 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -13,29 +13,45 @@ const AuthContext = createContext(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(null) - const [loading, setLoading] = useState(true) + // Inicialización sincrónica desde localStorage — 0ms, sin red + const [user, setUser] = useState(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(), []) diff --git a/src/auth/SupabaseAuthService.ts b/src/auth/SupabaseAuthService.ts index 5304f64..e444a57 100644 --- a/src/auth/SupabaseAuthService.ts +++ b/src/auth/SupabaseAuthService.ts @@ -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 }): Promise { + // 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 }): 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', } } } diff --git a/src/domain/types.ts b/src/domain/types.ts index a3a05bd..927c270 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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 diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index a2bd0f8..d7493e1 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -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((_, reject) => + setTimeout( + () => reject(new Error('Timeout: Supabase no respondió en 20s — verificá tu conexión')), + 20_000, + ) + ), ]) toast({ diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index d187130..b713a5f 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -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([]) @@ -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 ( +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ {[1, 2].map((i) => ( +
+ ))} +
+
+ ) + } + + // Empty state: sin grupos ni procesos (ya terminó de cargar) if (groups.length === 0 && processes.length === 0) { return (
@@ -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(null) const [transitioning, setTransitioning] = useState(false) @@ -670,29 +689,23 @@ export function LibraryPage() {
- {/* Contenido con transición */} - {isLoading ? ( -
-
-
- ) : ( -
- {view === 'home' ? ( - - ) : ( - - )} -
- )} + {/* Contenido — sin spinner bloqueante: skeleton dentro de HomeView */} +
+ {view === 'home' ? ( + + ) : ( + + )} +
) diff --git a/src/features/report/useReportData.ts b/src/features/report/useReportData.ts index 29ddfdb..89d8c58 100644 --- a/src/features/report/useReportData.ts +++ b/src/features/report/useReportData.ts @@ -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') diff --git a/src/features/workspace/ActivityPanel.tsx b/src/features/workspace/ActivityPanel.tsx index ec93eee..082c15c 100644 --- a/src/features/workspace/ActivityPanel.tsx +++ b/src/features/workspace/ActivityPanel.tsx @@ -136,8 +136,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { if (!localActivity) { return ( -
-

Esta actividad no tiene configuración todavía

+
+

Actividad no encontrada en la base de datos.

+

+ Si importaste este proceso antes de la última actualización, volvé a importar el archivo BPMN. +

) } diff --git a/src/features/workspace/ResourcesPanel.tsx b/src/features/workspace/ResourcesPanel.tsx index 783c5f5..96bd2e6 100644 --- a/src/features/workspace/ResourcesPanel.tsx +++ b/src/features/workspace/ResourcesPanel.tsx @@ -71,7 +71,6 @@ export function ResourcesPanel() { } else { const newResource: Resource = { id: uuidv4(), - processId: currentProcess.id, name: form.name.trim(), type: form.type, costPerHour, diff --git a/src/persistence/supabase/activity-repo.ts b/src/persistence/supabase/activity-repo.ts new file mode 100644 index 0000000..6e1e36f --- /dev/null +++ b/src/persistence/supabase/activity-repo.ts @@ -0,0 +1,183 @@ +import { supabase } from '@/lib/supabase' +import type { Activity, ActivityResourceAssignment } from '@/domain/types' + +function fromRow( + row: Record, + 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): 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): { + actual: ActivityResourceAssignment[] + automated: ActivityResourceAssignment[] +} { + const raw = (row.activity_resource_assignments as Array>) ?? [] + 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 { + 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 { + 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 { + 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) + return fromRow(row as Record, actual, automated) + }) + }, + + // Un solo round-trip: actividad + assignments anidados + async getByBpmnElementId(processId: string, bpmnElementId: string): Promise { + 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) + return fromRow(data as Record, actual, automated) + }, + + async deleteByProcess(processId: string): Promise { + // CASCADE elimina activity_resource_assignments automáticamente + const { error } = await supabase.from('activities').delete().eq('process_id', processId) + if (error) throw error + }, +} diff --git a/src/persistence/supabase/gateway-repo.ts b/src/persistence/supabase/gateway-repo.ts new file mode 100644 index 0000000..8495885 --- /dev/null +++ b/src/persistence/supabase/gateway-repo.ts @@ -0,0 +1,55 @@ +import { supabase } from '@/lib/supabase' +import type { GatewayConfig } from '@/domain/types' + +function fromRow(row: Record): 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 { + 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 { + 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 { + 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 { + const { error } = await supabase.from('gateways').delete().eq('process_id', processId) + if (error) throw error + }, +} diff --git a/src/persistence/supabase/process-repo.ts b/src/persistence/supabase/process-repo.ts index 3392220..bb8aed9 100644 --- a/src/persistence/supabase/process-repo.ts +++ b/src/persistence/supabase/process-repo.ts @@ -66,14 +66,12 @@ export const supabaseProcessRepo = { }, async delete(id: string): Promise { + // 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() }) }, diff --git a/src/persistence/supabase/resource-repo.ts b/src/persistence/supabase/resource-repo.ts new file mode 100644 index 0000000..d0cc24e --- /dev/null +++ b/src/persistence/supabase/resource-repo.ts @@ -0,0 +1,40 @@ +import { supabase } from '@/lib/supabase' +import type { Resource } from '@/domain/types' + +function fromRow(row: Record): 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 { + 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 { + 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 { + const { error } = await supabase.from('resources').delete().eq('id', resourceId) + if (error) throw error + }, +} diff --git a/src/router.tsx b/src/router.tsx index 3430a6c..7c2af06 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -47,7 +47,11 @@ function RootComponent() { ) } - if (!user && pathname !== '/login') return null + if (!user && pathname !== '/login') return ( +
+ +
+ ) return } diff --git a/src/store/library-store.ts b/src/store/library-store.ts index 6919e69..4a6b66a 100644 --- a/src/store/library-store.ts +++ b/src/store/library-store.ts @@ -39,10 +39,19 @@ export const useLibraryStore = create((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((_, 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) { diff --git a/src/store/process-store.ts b/src/store/process-store.ts index 7fc09ba..3b6562e 100644 --- a/src/store/process-store.ts +++ b/src/store/process-store.ts @@ -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((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((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((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((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((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((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((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() } diff --git a/supabase/migrations/005_create_resources.sql b/supabase/migrations/005_create_resources.sql new file mode 100644 index 0000000..52b97c7 --- /dev/null +++ b/supabase/migrations/005_create_resources.sql @@ -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); diff --git a/supabase/migrations/006_create_activities.sql b/supabase/migrations/006_create_activities.sql new file mode 100644 index 0000000..3d72bee --- /dev/null +++ b/supabase/migrations/006_create_activities.sql @@ -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); diff --git a/supabase/migrations/007_create_activity_resource_assignments.sql b/supabase/migrations/007_create_activity_resource_assignments.sql new file mode 100644 index 0000000..dffbf31 --- /dev/null +++ b/supabase/migrations/007_create_activity_resource_assignments.sql @@ -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); diff --git a/supabase/migrations/008_create_gateways.sql b/supabase/migrations/008_create_gateways.sql new file mode 100644 index 0000000..40042c1 --- /dev/null +++ b/supabase/migrations/008_create_gateways.sql @@ -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);