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