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:
@@ -18,6 +18,7 @@ export interface Process {
|
||||
updatedAt: number
|
||||
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
|
||||
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'
|
||||
|
||||
@@ -80,6 +80,7 @@ export function ImportPage() {
|
||||
updatedAt: now,
|
||||
groupId: targetGroupId,
|
||||
tags: [],
|
||||
ownerId: user!.id,
|
||||
}
|
||||
|
||||
const activityElements = extractActivityElements(graph)
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Upload, Search, Plus, Building2, FolderX, GitBranch,
|
||||
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
||||
MoreVertical, Trash2,
|
||||
MoreVertical, Trash2, Share2, Lock, User,
|
||||
} from 'lucide-react'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
@@ -43,6 +44,48 @@ function matchesTags(p: Process, activeTags: string[]): boolean {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) {
|
||||
@@ -80,15 +123,18 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
|
||||
|
||||
// ─── Process Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }: {
|
||||
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
|
||||
process: Process
|
||||
simulation: Simulation | null | undefined
|
||||
showGroupChip?: boolean
|
||||
groupName?: string
|
||||
onDelete?: () => void
|
||||
currentUserId: string
|
||||
onShared?: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
|
||||
const isOwn = process.ownerId === currentUserId
|
||||
|
||||
// Cierra el menú al hacer click fuera de él
|
||||
useEffect(() => {
|
||||
@@ -114,7 +160,10 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }
|
||||
|
||||
{/* Info central */}
|
||||
<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">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
|
||||
@@ -156,9 +205,23 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }
|
||||
</button>
|
||||
{menuState === 'menu' && (
|
||||
<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()}
|
||||
>
|
||||
{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
|
||||
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') }}
|
||||
@@ -308,22 +371,32 @@ function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: str
|
||||
function HomeView({
|
||||
onNavigateToGroup,
|
||||
onImport,
|
||||
ownerFilter,
|
||||
currentUserId,
|
||||
onShared,
|
||||
}: {
|
||||
onNavigateToGroup: (groupId: string | null) => void
|
||||
onImport: (groupId?: string) => void
|
||||
ownerFilter: 'all' | 'mine'
|
||||
currentUserId: string
|
||||
onShared: () => void
|
||||
}) {
|
||||
const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
|
||||
const { user } = useAuth()
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
|
||||
const cloud = tagCloudData(processes)
|
||||
const unclassifiedProcesses = processes.filter((p) => p.groupId == null)
|
||||
const visibleProcesses = ownerFilter === 'mine'
|
||||
? 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 filteredProcesses = isSearching
|
||||
? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
||||
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
||||
: []
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
@@ -420,6 +493,8 @@ function HomeView({
|
||||
process={p}
|
||||
showGroupChip
|
||||
groupName={group?.name}
|
||||
currentUserId={currentUserId}
|
||||
onShared={onShared}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -475,7 +550,7 @@ function HomeView({
|
||||
{/* Grid de grupos */}
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
||||
{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
|
||||
? Math.max(...groupProcesses.map((p) => p.updatedAt))
|
||||
: null
|
||||
@@ -521,10 +596,12 @@ function HomeView({
|
||||
|
||||
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
|
||||
|
||||
function ProcessCardWithSim({ process, showGroupChip, groupName }: {
|
||||
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
|
||||
process: Process
|
||||
showGroupChip?: boolean
|
||||
groupName?: string
|
||||
currentUserId: string
|
||||
onShared: () => void
|
||||
}) {
|
||||
const { getLatestSimulation, deleteProcess } = useLibraryStore()
|
||||
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
|
||||
@@ -544,6 +621,8 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
|
||||
showGroupChip={showGroupChip}
|
||||
groupName={groupName}
|
||||
onDelete={() => deleteProcess(process.id)}
|
||||
currentUserId={currentUserId}
|
||||
onShared={onShared}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -553,16 +632,27 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
|
||||
function GroupView({
|
||||
groupId,
|
||||
onBack,
|
||||
currentUserId,
|
||||
ownerFilter,
|
||||
onShared,
|
||||
}: {
|
||||
groupId: string | null
|
||||
onBack: () => void
|
||||
currentUserId: string
|
||||
ownerFilter: 'all' | 'mine'
|
||||
onShared: () => void
|
||||
}) {
|
||||
const { groups, processes, settings } = useLibraryStore()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const group = groupId ? groups.find((g) => g.id === groupId) : null
|
||||
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) => matchesSearch(p, search))
|
||||
|
||||
@@ -609,7 +699,12 @@ function GroupView({
|
||||
{/* Lista de procesos */}
|
||||
<div className="space-y-2">
|
||||
{groupProcesses.map((p) => (
|
||||
<ProcessCardWithSim key={p.id} process={p} />
|
||||
<ProcessCardWithSim
|
||||
key={p.id}
|
||||
process={p}
|
||||
currentUserId={currentUserId}
|
||||
onShared={onShared}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,9 +716,12 @@ function GroupView({
|
||||
export function LibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { load, processes, groups, settings } = useLibraryStore()
|
||||
const { user } = useAuth()
|
||||
const [view, setView] = useState<'home' | 'group'>('home')
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
|
||||
const { message: toastMessage, show: showToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -671,6 +769,30 @@ export function LibraryPage() {
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
@@ -698,15 +820,23 @@ export function LibraryPage() {
|
||||
<HomeView
|
||||
onNavigateToGroup={navigateToGroup}
|
||||
onImport={handleImport}
|
||||
ownerFilter={ownerFilter}
|
||||
currentUserId={currentUserId}
|
||||
onShared={() => showToast('Proceso compartido con el equipo')}
|
||||
/>
|
||||
) : (
|
||||
<GroupView
|
||||
groupId={activeGroupId}
|
||||
onBack={navigateHome}
|
||||
currentUserId={currentUserId}
|
||||
ownerFilter={ownerFilter}
|
||||
onShared={() => showToast('Proceso compartido con el equipo')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toast message={toastMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ function toRow(process: Process, userId: string) {
|
||||
automation_investment: process.automationInvestment,
|
||||
group_id: process.groupId,
|
||||
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,
|
||||
updated_by: userId,
|
||||
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[]) : [],
|
||||
createdAt: new Date(row.created_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)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
38
supabase/migrations/010_create_user_groups.sql
Normal file
38
supabase/migrations/010_create_user_groups.sql
Normal 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()
|
||||
)
|
||||
);
|
||||
21
supabase/migrations/011_create_process_access.sql
Normal file
21
supabase/migrations/011_create_process_access.sql
Normal 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()
|
||||
)
|
||||
);
|
||||
53
supabase/migrations/012_update_processes_rls.sql
Normal file
53
supabase/migrations/012_update_processes_rls.sql
Normal 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());
|
||||
@@ -229,7 +229,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||
bpmnXml: '<def/>', currency: 'USD',
|
||||
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()
|
||||
})
|
||||
@@ -239,7 +239,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
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} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -251,7 +251,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
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} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -338,6 +338,7 @@ describe('ReportPage — con datos completos', () => {
|
||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||
groupId: null,
|
||||
tags: [],
|
||||
ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockSimulation = {
|
||||
|
||||
@@ -590,7 +590,7 @@ const mockProcessBase = {
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockSimulationBase = {
|
||||
|
||||
@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
|
||||
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
|
||||
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
|
||||
bpmnXml: '', currency, overheadPercentage: 0.2,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
|
||||
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const simulation: Simulation = {
|
||||
|
||||
@@ -51,7 +51,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const perActivityActual = [
|
||||
|
||||
@@ -43,7 +43,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
// Simulación SIN recursos en ninguna actividad
|
||||
|
||||
@@ -54,7 +54,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -59,7 +59,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -56,7 +56,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -52,7 +52,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockRoi: RoiResult = {
|
||||
|
||||
@@ -52,7 +52,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
|
||||
createdAt: Date.now(), updatedAt: Date.now(),
|
||||
groupId: null, tags: [],
|
||||
groupId: null, tags: [], ownerId: 'test-user',
|
||||
}
|
||||
useProcessStore.setState({ currentProcess: proc })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user