fix: navigation correctness - back buttons, exit dialog, dedup nav, redirects
- Standardize all procedural back/exit paths to /trees (not /my-trees) - Add exit button with ConfirmDialog to procedural session top bar - Consolidate duplicate account links in sidebar and topbar - Auto-redirect non-owners to personal analytics - Add toast feedback before silent permission redirects in tree editor - Delete orphaned AdminCategoriesPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Users, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { CategoryList } from '@/components/sidebar/CategoryList'
|
||||
@@ -203,8 +203,7 @@ export function Sidebar() {
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
|
||||
<NavItem href="/account" icon={Users} label="Team" />
|
||||
<NavItem href="/account" icon={Settings} label="Settings" />
|
||||
<NavItem href="/account" icon={Settings} label="Account" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Search, Zap, LogOut, User, Shield, Settings } from 'lucide-react'
|
||||
import { Search, Zap, LogOut, Shield, Settings } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
@@ -122,21 +122,13 @@ export function TopBar() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<User size={14} />
|
||||
Account
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
Account
|
||||
</Link>
|
||||
{isSuperAdmin && (
|
||||
<Link
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
|
||||
import { stepCategoriesApi } from '@/api/stepCategories'
|
||||
import { stepsApi } from '@/api/steps'
|
||||
import { CategoryRow } from '@/components/admin/CategoryRow'
|
||||
import { CreateCategoryModal } from '@/components/admin/CreateCategoryModal'
|
||||
import { EditCategoryModal } from '@/components/admin/EditCategoryModal'
|
||||
import type { StepCategoryListItem } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<StepCategoryListItem[]>([])
|
||||
const [allSteps, setAllSteps] = useState<{ category_id?: string }[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<StepCategoryListItem | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [includeArchived, setIncludeArchived] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [includeArchived])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [categoriesData, stepsData] = await Promise.all([
|
||||
stepCategoriesApi.list({ include_inactive: includeArchived }),
|
||||
stepsApi.list({})
|
||||
])
|
||||
setCategories(categoriesData)
|
||||
setAllSteps(stepsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err)
|
||||
toast.error('Failed to load categories')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStepCount = (categoryId: string) => {
|
||||
return allSteps?.filter(s => s.category_id === categoryId).length || 0
|
||||
}
|
||||
|
||||
const handleCreate = async (data: { name: string; description: string }) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await stepCategoriesApi.create({
|
||||
name: data.name,
|
||||
description: data.description || undefined
|
||||
})
|
||||
toast.success('Category created successfully')
|
||||
setShowCreateModal(false)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to create category:', err)
|
||||
toast.error('Failed to create category')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (data: { name: string; description: string }) => {
|
||||
if (!editingCategory) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await stepCategoriesApi.update(editingCategory.id, {
|
||||
name: data.name,
|
||||
description: data.description || undefined
|
||||
})
|
||||
toast.success('Category updated successfully')
|
||||
setShowEditModal(false)
|
||||
setEditingCategory(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to update category:', err)
|
||||
toast.error('Failed to update category')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await stepCategoriesApi.archive(id)
|
||||
toast.success('Category archived')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to archive category:', err)
|
||||
toast.error('Failed to archive category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await stepCategoriesApi.restore(id)
|
||||
toast.success('Category restored')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to restore category:', err)
|
||||
toast.error('Failed to restore category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = categories.findIndex(c => c.id === active.id)
|
||||
const newIndex = categories.findIndex(c => c.id === over.id)
|
||||
|
||||
const reordered = arrayMove(categories, oldIndex, newIndex)
|
||||
|
||||
// Optimistic update
|
||||
setCategories(reordered)
|
||||
|
||||
try {
|
||||
// Update display_order for all affected categories
|
||||
const updates = reordered.map((cat, index) => ({
|
||||
id: cat.id,
|
||||
display_order: index
|
||||
}))
|
||||
await stepCategoriesApi.updateOrder(updates)
|
||||
toast.success('Categories reordered')
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder categories:', err)
|
||||
toast.error('Failed to save order')
|
||||
// Revert on error
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (category: StepCategoryListItem) => {
|
||||
setEditingCategory(category)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">
|
||||
Step Categories
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage categories for organizing step library
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeArchived}
|
||||
onChange={(e) => setIncludeArchived(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-foreground focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show archived categories</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Categories List */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="bg-card border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No categories found. Create your first category to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={categories.map(c => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{categories.map(category => (
|
||||
<CategoryRow
|
||||
key={category.id}
|
||||
category={category}
|
||||
stepCount={getStepCount(category.id)}
|
||||
onEdit={openEditModal}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<CreateCategoryModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<EditCategoryModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
category={editingCategory}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminCategoriesPage
|
||||
@@ -83,13 +83,13 @@ export function ProceduralEditorPage() {
|
||||
const tree = await treesApi.get(treeId)
|
||||
if (tree.tree_type !== 'procedural' && tree.tree_type !== 'maintenance') {
|
||||
toast.error('This flow is not a procedural or maintenance flow')
|
||||
navigate('/my-trees')
|
||||
navigate('/trees')
|
||||
return
|
||||
}
|
||||
loadTree(tree)
|
||||
} catch {
|
||||
toast.error('Failed to load flow')
|
||||
navigate('/my-trees')
|
||||
navigate('/trees')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ export function ProceduralEditorPage() {
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/my-trees')}
|
||||
onClick={() => navigate('/trees')}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StepChecklist } from '@/components/procedural/StepChecklist'
|
||||
import { StepDetail } from '@/components/procedural/StepDetail'
|
||||
import { ProgressBar } from '@/components/procedural/ProgressBar'
|
||||
import { CompletionSummary } from '@/components/procedural/CompletionSummary'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
@@ -35,6 +36,7 @@ export function ProceduralNavigationPage() {
|
||||
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [completedAt, setCompletedAt] = useState<string>('')
|
||||
const [showExitConfirm, setShowExitConfirm] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [paramsOpen, setParamsOpen] = useState(false)
|
||||
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||
@@ -117,7 +119,7 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load flow')
|
||||
navigate('/my-trees')
|
||||
navigate('/trees')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -177,7 +179,7 @@ export function ProceduralNavigationPage() {
|
||||
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||
} catch {
|
||||
toast.error('Failed to resume session')
|
||||
navigate('/my-trees')
|
||||
navigate('/trees')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +303,7 @@ export function ProceduralNavigationPage() {
|
||||
fields={tree.intake_form || []}
|
||||
treeName={tree.name}
|
||||
onSubmit={handleIntakeSubmit}
|
||||
onCancel={() => navigate('/my-trees')}
|
||||
onCancel={() => navigate('/trees')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -327,7 +329,7 @@ export function ProceduralNavigationPage() {
|
||||
startedAt={session.started_at}
|
||||
completedAt={completedAt}
|
||||
onExport={() => navigate(`/sessions/${session.id}`)}
|
||||
onClose={() => navigate('/my-trees')}
|
||||
onClose={() => navigate('/trees')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -354,6 +356,19 @@ export function ProceduralNavigationPage() {
|
||||
<ListOrdered className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-sm font-semibold text-foreground sm:text-base">{tree.name}</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentStepIndex > 0) {
|
||||
setShowExitConfirm(true)
|
||||
} else {
|
||||
navigate('/trees')
|
||||
}
|
||||
}}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="mr-1 inline h-3.5 w-3.5" />
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
@@ -433,6 +448,15 @@ export function ProceduralNavigationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showExitConfirm}
|
||||
onClose={() => setShowExitConfirm(false)}
|
||||
onConfirm={() => navigate('/trees')}
|
||||
title="Exit Session"
|
||||
message="You have progress in this session. Are you sure you want to exit? Your progress will not be saved."
|
||||
confirmLabel="Exit"
|
||||
/>
|
||||
|
||||
{/* Parameters popover */}
|
||||
{paramsOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BarChart3, Loader2, Users, Target, Clock, TrendingUp, ShieldX } from 'lucide-react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { BarChart3, Loader2, Users, Target, Clock, TrendingUp } from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -44,25 +44,8 @@ export default function TeamAnalyticsPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [period, isAccountOwner, isSuperAdmin])
|
||||
|
||||
// Permission guard
|
||||
if (!isAccountOwner && !isSuperAdmin) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-6">
|
||||
<ShieldX size={48} className="text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold text-foreground">Access Denied</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
Team Analytics is only available to account owners and administrators.
|
||||
You can view your personal stats instead.
|
||||
</p>
|
||||
<Link
|
||||
to="/analytics/me"
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<TrendingUp size={16} />
|
||||
View My Stats
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
return <Navigate to="/analytics/me" replace />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -141,6 +141,7 @@ export function TreeEditorPage() {
|
||||
// Permission guard: redirect viewers away from editor
|
||||
useEffect(() => {
|
||||
if (!canCreateTrees) {
|
||||
toast.error("You don't have permission to edit flows")
|
||||
navigate('/trees')
|
||||
}
|
||||
}, [canCreateTrees, navigate])
|
||||
@@ -155,6 +156,7 @@ export function TreeEditorPage() {
|
||||
try {
|
||||
const tree = await treesApi.get(id)
|
||||
if (!canEditTree({ author_id: tree.author_id, account_id: tree.account_id })) {
|
||||
toast.error("You don't have permission to edit this flow")
|
||||
navigate('/trees')
|
||||
return
|
||||
}
|
||||
@@ -162,6 +164,7 @@ export function TreeEditorPage() {
|
||||
setTreeStatus(tree.status) // Load status from existing tree
|
||||
} catch (err) {
|
||||
console.error('Failed to load tree:', err)
|
||||
toast.error('Failed to load flow')
|
||||
navigate('/trees')
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -7,4 +7,3 @@ export { default as TreeEditorPage } from './TreeEditorPage'
|
||||
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
||||
export { default as SessionDetailPage } from './SessionDetailPage'
|
||||
export { default as AccountSettingsPage } from './AccountSettingsPage'
|
||||
export { default as AdminCategoriesPage } from './AdminCategoriesPage'
|
||||
|
||||
Reference in New Issue
Block a user