feat(sprint-4/etapa-5): modelo de privacidad RLS granular + UI de visibilidad

- SQL 010: tablas user_groups y group_memberships con RLS
- SQL 011: tabla process_access para acceso explícito a procesos
- SQL 012: función is_platform_admin() + políticas granulares (select/insert/update/delete) en processes
- types.ts: Process.ownerId (requerido)
- process-repo: fromRow mapea owner_id→ownerId; toRow preserva ownerId en updates; setShared() no-op documentado
- ImportPage: asigna ownerId: user.id al crear proceso
- LibraryPage: toggle Todos/Míos, OwnershipBadge (🔒/👤) en cada tarjeta, opción "Compartir con equipo" en menú de proceso propio con toast de confirmación
- Tests: ownerId: 'test-user' agregado a todos los fixtures de Process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 22:37:37 -03:00
parent 025393cd43
commit d426791a53
20 changed files with 281 additions and 28 deletions

View File

@@ -18,6 +18,7 @@ export interface Process {
updatedAt: number updatedAt: number
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar") groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
} }
export type ActivityType = 'task' | 'subprocess' export type ActivityType = 'task' | 'subprocess'

View File

@@ -80,6 +80,7 @@ export function ImportPage() {
updatedAt: now, updatedAt: now,
groupId: targetGroupId, groupId: targetGroupId,
tags: [], tags: [],
ownerId: user!.id,
} }
const activityElements = extractActivityElements(graph) const activityElements = extractActivityElements(graph)

View File

@@ -3,10 +3,11 @@ import { useNavigate } from '@tanstack/react-router'
import { import {
Upload, Search, Plus, Building2, FolderX, GitBranch, Upload, Search, Plus, Building2, FolderX, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings, Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2, MoreVertical, Trash2, Share2, Lock, User,
} from 'lucide-react' } from 'lucide-react'
import { useLibraryStore } from '@/store/library-store' import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { formatCurrency } from '@/lib/format' import { formatCurrency } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types' import type { Process, ProcessGroup, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader' import { AppHeader } from '@/components/AppHeader'
@@ -43,6 +44,48 @@ function matchesTags(p: Process, activeTags: string[]): boolean {
return activeTags.every((t) => p.tags.includes(t)) return activeTags.every((t) => p.tags.includes(t))
} }
// ─── Ownership Badge ──────────────────────────────────────────────────────────
function OwnershipBadge({ process, currentUserId }: { process: Process; currentUserId: string }) {
const isOwn = process.ownerId === currentUserId
return (
<div
className="flex-shrink-0 flex items-center"
title={isOwn ? 'Solo vos' : 'De otro consultor'}
>
{isOwn
? <Lock className="h-3 w-3 text-muted-foreground/60" />
: <User className="h-3 w-3 text-muted-foreground/60" />
}
</div>
)
}
// ─── Toast ────────────────────────────────────────────────────────────────────
function useToast() {
const [message, setMessage] = useState<string | null>(null)
function show(msg: string) {
setMessage(msg)
setTimeout(() => setMessage(null), 2800)
}
return { message, show }
}
function Toast({ message }: { message: string | null }) {
if (!message) return null
return (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg text-[13px] font-medium text-white shadow-lg transition-all"
style={{ background: '#15803d' }}
>
{message}
</div>
)
}
// ─── KPI Badge ──────────────────────────────────────────────────────────────── // ─── KPI Badge ────────────────────────────────────────────────────────────────
function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) { function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) {
@@ -80,15 +123,18 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ───────────────────────────────────────────────────────────── // ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }: { function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
process: Process process: Process
simulation: Simulation | null | undefined simulation: Simulation | null | undefined
showGroupChip?: boolean showGroupChip?: boolean
groupName?: string groupName?: string
onDelete?: () => void onDelete?: () => void
currentUserId: string
onShared?: () => void
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null) const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
const isOwn = process.ownerId === currentUserId
// Cierra el menú al hacer click fuera de él // Cierra el menú al hacer click fuera de él
useEffect(() => { useEffect(() => {
@@ -114,7 +160,10 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }
{/* Info central */} {/* Info central */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p> <div className="flex items-center gap-1.5">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<OwnershipBadge process={process} currentUserId={currentUserId} />
</div>
<div className="flex items-center gap-1 mt-0.5"> <div className="flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3 text-muted-foreground" /> <Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span> <span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
@@ -156,9 +205,23 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }
</button> </button>
{menuState === 'menu' && ( {menuState === 'menu' && (
<div <div
className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[130px]" className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{isOwn && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-foreground hover:bg-secondary transition-colors"
onClick={async (e) => {
e.stopPropagation()
await supabaseProcessRepo.setShared(process.id, true)
setMenuState(null)
onShared?.()
}}
>
<Share2 className="h-3.5 w-3.5" />
Compartir con equipo
</button>
)}
<button <button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-destructive hover:bg-destructive/10 transition-colors" className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-destructive hover:bg-destructive/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setMenuState('confirm') }} onClick={(e) => { e.stopPropagation(); setMenuState('confirm') }}
@@ -308,22 +371,32 @@ function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: str
function HomeView({ function HomeView({
onNavigateToGroup, onNavigateToGroup,
onImport, onImport,
ownerFilter,
currentUserId,
onShared,
}: { }: {
onNavigateToGroup: (groupId: string | null) => void onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void onImport: (groupId?: string) => void
ownerFilter: 'all' | 'mine'
currentUserId: string
onShared: () => void
}) { }) {
const { groups, processes, settings, createGroup, isLoading } = 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[]>([])
const cloud = tagCloudData(processes) const visibleProcesses = ownerFilter === 'mine'
const unclassifiedProcesses = processes.filter((p) => p.groupId == null) ? processes.filter((p) => p.ownerId === currentUserId)
: processes
const cloud = tagCloudData(visibleProcesses)
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
const isSearching = search.trim().length > 0 || activeTags.length > 0 const isSearching = search.trim().length > 0 || activeTags.length > 0
const filteredProcesses = isSearching const filteredProcesses = isSearching
? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags)) ? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
: [] : []
function toggleTag(tag: string) { function toggleTag(tag: string) {
@@ -420,6 +493,8 @@ function HomeView({
process={p} process={p}
showGroupChip showGroupChip
groupName={group?.name} groupName={group?.name}
currentUserId={currentUserId}
onShared={onShared}
/> />
) )
})} })}
@@ -475,7 +550,7 @@ function HomeView({
{/* Grid de grupos */} {/* Grid de grupos */}
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}> <div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{groups.map((group) => { {groups.map((group) => {
const groupProcesses = processes.filter((p) => p.groupId === group.id) const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
const lastUpdated = groupProcesses.length > 0 const lastUpdated = groupProcesses.length > 0
? Math.max(...groupProcesses.map((p) => p.updatedAt)) ? Math.max(...groupProcesses.map((p) => p.updatedAt))
: null : null
@@ -521,10 +596,12 @@ function HomeView({
// ─── ProcessCardWithSim (carga simulación lazy) ─────────────────────────────── // ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName }: { function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
process: Process process: Process
showGroupChip?: boolean showGroupChip?: boolean
groupName?: string groupName?: string
currentUserId: string
onShared: () => void
}) { }) {
const { getLatestSimulation, deleteProcess } = useLibraryStore() const { getLatestSimulation, deleteProcess } = useLibraryStore()
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined) const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
@@ -544,6 +621,8 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
showGroupChip={showGroupChip} showGroupChip={showGroupChip}
groupName={groupName} groupName={groupName}
onDelete={() => deleteProcess(process.id)} onDelete={() => deleteProcess(process.id)}
currentUserId={currentUserId}
onShared={onShared}
/> />
) )
} }
@@ -553,16 +632,27 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
function GroupView({ function GroupView({
groupId, groupId,
onBack, onBack,
currentUserId,
ownerFilter,
onShared,
}: { }: {
groupId: string | null groupId: string | null
onBack: () => void onBack: () => void
currentUserId: string
ownerFilter: 'all' | 'mine'
onShared: () => void
}) { }) {
const { groups, processes, settings } = useLibraryStore() const { groups, processes, settings } = useLibraryStore()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const group = groupId ? groups.find((g) => g.id === groupId) : null const group = groupId ? groups.find((g) => g.id === groupId) : null
const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}` const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}`
const groupProcesses = processes
const baseProcesses = ownerFilter === 'mine'
? processes.filter((p) => p.ownerId === currentUserId)
: processes
const groupProcesses = baseProcesses
.filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId) .filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId)
.filter((p) => matchesSearch(p, search)) .filter((p) => matchesSearch(p, search))
@@ -609,7 +699,12 @@ function GroupView({
{/* Lista de procesos */} {/* Lista de procesos */}
<div className="space-y-2"> <div className="space-y-2">
{groupProcesses.map((p) => ( {groupProcesses.map((p) => (
<ProcessCardWithSim key={p.id} process={p} /> <ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
onShared={onShared}
/>
))} ))}
</div> </div>
</div> </div>
@@ -621,9 +716,12 @@ function GroupView({
export function LibraryPage() { export function LibraryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { load, processes, groups, settings } = useLibraryStore() const { load, processes, groups, settings } = useLibraryStore()
const { user } = useAuth()
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)
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
const { message: toastMessage, show: showToast } = useToast()
useEffect(() => { useEffect(() => {
load() load()
@@ -655,7 +753,7 @@ export function LibraryPage() {
} }
} }
// Preflight fix: contar grupos reales en lugar del clientName heredado const currentUserId = user!.id
const totalGroups = groups.length const totalGroups = groups.length
return ( return (
@@ -671,6 +769,30 @@ export function LibraryPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Toggle Todos / Míos */}
<div className="flex rounded-lg border border-border overflow-hidden text-[12px]">
<button
className="px-3 py-1.5 font-medium transition-colors"
style={ownerFilter === 'all'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('all')}
>
Todos
</button>
<button
className="px-3 py-1.5 font-medium transition-colors border-l border-border"
style={ownerFilter === 'mine'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('mine')}
>
Míos
</button>
</div>
<button <button
onClick={() => navigate({ to: '/settings' })} onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors" className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
@@ -698,15 +820,23 @@ export function LibraryPage() {
<HomeView <HomeView
onNavigateToGroup={navigateToGroup} onNavigateToGroup={navigateToGroup}
onImport={handleImport} onImport={handleImport}
ownerFilter={ownerFilter}
currentUserId={currentUserId}
onShared={() => showToast('Proceso compartido con el equipo')}
/> />
) : ( ) : (
<GroupView <GroupView
groupId={activeGroupId} groupId={activeGroupId}
onBack={navigateHome} onBack={navigateHome}
currentUserId={currentUserId}
ownerFilter={ownerFilter}
onShared={() => showToast('Proceso compartido con el equipo')}
/> />
)} )}
</div> </div>
</div> </div>
<Toast message={toastMessage} />
</div> </div>
) )
} }

View File

@@ -14,7 +14,8 @@ function toRow(process: Process, userId: string) {
automation_investment: process.automationInvestment, automation_investment: process.automationInvestment,
group_id: process.groupId, group_id: process.groupId,
tags: process.tags, tags: process.tags,
owner_id: userId, // owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
owner_id: process.ownerId || userId,
created_by: userId, created_by: userId,
updated_by: userId, updated_by: userId,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@@ -36,6 +37,7 @@ function fromRow(row: Record<string, unknown>): Process {
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [], tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
createdAt: new Date(row.created_at as string).getTime(), createdAt: new Date(row.created_at as string).getTime(),
updatedAt: new Date(row.updated_at as string).getTime(), updatedAt: new Date(row.updated_at as string).getTime(),
ownerId: (row.owner_id as string) ?? '',
} }
} }
@@ -69,4 +71,10 @@ export const supabaseProcessRepo = {
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
}, },
// No-op en Sprint 4: la implementación real (insertar en process_access) llega cuando existan guests.
// El método existe para que la UI de "Compartir con equipo" funcione sin escritura a DB.
async setShared(_processId: string, _shared: boolean): Promise<void> {
return
},
} }

View File

@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS public.user_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.user_groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_groups" ON public.user_groups
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_groups" ON public.user_groups
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR created_by = auth.uid()
);
CREATE TABLE IF NOT EXISTS public.group_memberships (
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
group_id uuid NOT NULL REFERENCES public.user_groups(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member')),
PRIMARY KEY (user_id, group_id)
);
ALTER TABLE public.group_memberships ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_memberships" ON public.group_memberships
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_memberships" ON public.group_memberships
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR EXISTS (
SELECT 1 FROM public.user_groups
WHERE id = group_memberships.group_id AND created_by = auth.uid()
)
);

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS public.process_access (
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
grantee_type text NOT NULL CHECK (grantee_type IN ('user','group')),
grantee_id uuid NOT NULL,
permission text NOT NULL DEFAULT 'view' CHECK (permission IN ('view','edit')),
PRIMARY KEY (process_id, grantee_type, grantee_id)
);
ALTER TABLE public.process_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_process_access" ON public.process_access
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_process_access" ON public.process_access
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR EXISTS (
SELECT 1 FROM public.processes
WHERE id = process_access.process_id AND owner_id = auth.uid()
)
);

View File

@@ -0,0 +1,53 @@
-- Helper: retorna true si el usuario actual es platform_admin
CREATE OR REPLACE FUNCTION public.is_platform_admin()
RETURNS boolean
LANGUAGE sql SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND platform_role = 'platform_admin'
);
$$;
-- Eliminar política amplia de Sprint 4 Etapa 2
DROP POLICY IF EXISTS "authenticated_all_processes" ON public.processes;
-- Políticas granulares
CREATE POLICY "select_processes" ON public.processes
FOR SELECT USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "update_processes" ON public.processes
FOR UPDATE USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
CREATE POLICY "delete_processes" ON public.processes
FOR DELETE USING (public.is_platform_admin() OR owner_id = auth.uid());

View File

@@ -229,7 +229,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proceso Test', clientName: '', id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD', bpmnXml: '<def/>', currency: 'USD',
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user',
} }
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow() expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
}) })
@@ -239,7 +239,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '', id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD', bpmnXml: '', currency: 'USD',
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user',
} }
render(<MethodologyFooter process={proc} />) render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? '' const text = document.body.textContent ?? ''
@@ -251,7 +251,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '', id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD', bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user',
} }
render(<MethodologyFooter process={proc} />) render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? '' const text = document.body.textContent ?? ''
@@ -338,6 +338,7 @@ describe('ReportPage — con datos completos', () => {
updatedAt: Date.now() - 1000 * 60 * 30, updatedAt: Date.now() - 1000 * 60 * 30,
groupId: null, groupId: null,
tags: [], tags: [],
ownerId: 'test-user',
} }
const mockSimulation = { const mockSimulation = {

View File

@@ -590,7 +590,7 @@ const mockProcessBase = {
currency: 'USD', overheadPercentage: 0.2, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000, createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const mockSimulationBase = { const mockSimulationBase = {

View File

@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ', id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras, createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras,
} }
} }

View File

@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC', id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2, bpmnXml: '', currency, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras, createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras,
} }
} }

View File

@@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2, bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const simulation: Simulation = { const simulation: Simulation = {

View File

@@ -51,7 +51,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const perActivityActual = [ const perActivityActual = [

View File

@@ -43,7 +43,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
// Simulación SIN recursos en ninguna actividad // Simulación SIN recursos en ninguna actividad

View File

@@ -54,7 +54,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -59,7 +59,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -56,7 +56,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2, overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -52,7 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const mockRoi: RoiResult = { const mockRoi: RoiResult = {

View File

@@ -52,7 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
currency: 'USD', overheadPercentage: 0.2, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000, annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
createdAt: Date.now(), updatedAt: Date.now(), createdAt: Date.now(), updatedAt: Date.now(),
groupId: null, tags: [], groupId: null, tags: [], ownerId: 'test-user',
} }
useProcessStore.setState({ currentProcess: proc }) useProcessStore.setState({ currentProcess: proc })