diff --git a/frontend/src/api/pinnedFlows.ts b/frontend/src/api/pinnedFlows.ts index 41b3754a..6ac05d8d 100644 --- a/frontend/src/api/pinnedFlows.ts +++ b/frontend/src/api/pinnedFlows.ts @@ -4,7 +4,7 @@ export interface PinnedFlow { id: string tree_id: string tree_name: string - tree_type: 'troubleshooting' | 'procedural' + tree_type: 'troubleshooting' | 'procedural' | 'maintenance' category_emoji?: string category_name?: string pinned_at: string diff --git a/frontend/src/components/admin/EmptyState.tsx b/frontend/src/components/admin/EmptyState.tsx index 22e4a266..4e58f7a8 100644 --- a/frontend/src/components/admin/EmptyState.tsx +++ b/frontend/src/components/admin/EmptyState.tsx @@ -1,25 +1,2 @@ -import type { ReactNode } from 'react' -import { cn } from '@/lib/utils' - -interface EmptyStateProps { - icon?: ReactNode - title: string - description?: string - action?: ReactNode - className?: string -} - -export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { - return ( -
- {icon &&
{icon}
} -

{title}

- {description && ( -

{description}

- )} - {action &&
{action}
} -
- ) -} - -export default EmptyState +export { EmptyState } from '@/components/common/EmptyState' +export { default } from '@/components/common/EmptyState' diff --git a/frontend/src/components/common/EmptyState.tsx b/frontend/src/components/common/EmptyState.tsx new file mode 100644 index 00000000..22e4a266 --- /dev/null +++ b/frontend/src/components/common/EmptyState.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface EmptyStateProps { + icon?: ReactNode + title: string + description?: string + action?: ReactNode + className?: string +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ) +} + +export default EmptyState diff --git a/frontend/src/components/common/PageLoader.tsx b/frontend/src/components/common/PageLoader.tsx index 90621223..0db230bd 100644 --- a/frontend/src/components/common/PageLoader.tsx +++ b/frontend/src/components/common/PageLoader.tsx @@ -1,8 +1,10 @@ +import { Spinner } from '@/components/common/Spinner' + export function PageLoader() { return (
-
+

Loading...

diff --git a/frontend/src/components/common/Spinner.tsx b/frontend/src/components/common/Spinner.tsx new file mode 100644 index 00000000..4222704d --- /dev/null +++ b/frontend/src/components/common/Spinner.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils' + +const SIZES = { + sm: 'h-4 w-4 border-2', + md: 'h-8 w-8 border-4', + lg: 'h-12 w-12 border-4', +} as const + +interface SpinnerProps { + size?: keyof typeof SIZES + className?: string +} + +export function Spinner({ size = 'md', className }: SpinnerProps) { + return ( +
+ ) +} + +export default Spinner diff --git a/frontend/src/components/library/FolderSidebar.tsx b/frontend/src/components/library/FolderSidebar.tsx index 75c2a4dd..186fde17 100644 --- a/frontend/src/components/library/FolderSidebar.tsx +++ b/frontend/src/components/library/FolderSidebar.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react' import { foldersApi } from '@/api/folders' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' import type { FolderListItem, FolderTreeItem } from '@/types' import { cn } from '@/lib/utils' @@ -245,6 +246,7 @@ export function FolderSidebar({ const [menuOpenId, setMenuOpenId] = useState(null) const [expandedIds, setExpandedIds] = useState>(new Set()) const [contextMenu, setContextMenu] = useState(null) + const [pendingDelete, setPendingDelete] = useState<{ id: string; message: string } | null>(null) const loadFolders = useCallback(async () => { setIsLoading(true) @@ -277,15 +279,19 @@ export function FolderSidebar({ }) } - const handleDeleteFolder = async (folderId: string, folderHasChildren: boolean) => { + const handleDeleteFolder = (folderId: string, folderHasChildren: boolean) => { const descendantCount = getDescendantIds(folders, folderId).length const message = folderHasChildren ? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.` : 'Are you sure you want to delete this folder? The trees in it will not be deleted.' - if (!confirm(message)) { - return - } + setPendingDelete({ id: folderId, message }) + } + + const confirmDeleteFolder = async () => { + if (!pendingDelete) return + const folderId = pendingDelete.id + setPendingDelete(null) try { await foldersApi.delete(folderId) // Remove folder and all descendants from local state @@ -494,6 +500,15 @@ export function FolderSidebar({
)} + + setPendingDelete(null)} + onConfirm={confirmDeleteFolder} + title="Delete Folder" + message={pendingDelete?.message || ''} + confirmLabel="Delete" + /> ) } diff --git a/frontend/src/components/sidebar/PinnedFlowsSection.tsx b/frontend/src/components/sidebar/PinnedFlowsSection.tsx index 0dbacbf4..0e1ae165 100644 --- a/frontend/src/components/sidebar/PinnedFlowsSection.tsx +++ b/frontend/src/components/sidebar/PinnedFlowsSection.tsx @@ -46,7 +46,7 @@ export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) title={`${flow.tree_name} (right-click to unpin)`} > - {flow.tree_type === 'procedural' ? '📋' : '🔧'} + {flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'} {flow.tree_name} diff --git a/frontend/src/components/tree-editor/NodeEditorPanel.tsx b/frontend/src/components/tree-editor/NodeEditorPanel.tsx index b5487db7..39c52274 100644 --- a/frontend/src/components/tree-editor/NodeEditorPanel.tsx +++ b/frontend/src/components/tree-editor/NodeEditorPanel.tsx @@ -4,6 +4,7 @@ import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' import { NodeFormDecision } from './NodeFormDecision' import { NodeFormAction } from './NodeFormAction' import { NodeFormResolution } from './NodeFormResolution' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { cn } from '@/lib/utils' import type { TreeStructure, NodeType } from '@/types' @@ -36,6 +37,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const [draft, setDraft] = useState(null) const [isDirty, setIsDirty] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) const panelRef = useRef(null) // Initialize/reset draft when nodeId changes @@ -84,7 +86,8 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const handleClose = useCallback(() => { if (isDirty) { - if (!window.confirm('You have unsaved changes. Discard them?')) return + setShowDiscardConfirm(true) + return } onClose() }, [isDirty, onClose]) @@ -236,6 +239,18 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan )}
+ + setShowDiscardConfirm(false)} + onConfirm={() => { + setShowDiscardConfirm(false) + onClose() + }} + title="Discard Changes" + message="You have unsaved changes. Discard them?" + confirmLabel="Discard" + /> ) } diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index a3e0084b..e6b3c58a 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react' import { accountsApi } from '@/api/accounts' import type { Account, AccountMember, AccountInvite } from '@/types' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { usePermissions } from '@/hooks/usePermissions' import { useSubscription } from '@/hooks/useSubscription' @@ -130,7 +131,7 @@ export function AccountSettingsPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index 3670d601..1ead480a 100644 --- a/frontend/src/pages/MyAnalyticsPage.tsx +++ b/frontend/src/pages/MyAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { BarChart3, Loader2, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react' +import { BarChart3, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react' import { AreaChart, Area, @@ -10,6 +10,7 @@ import { Tooltip, ResponsiveContainer, } from 'recharts' +import { Spinner } from '@/components/common/Spinner' import { analyticsApi } from '@/api' import { usePermissions } from '@/hooks/usePermissions' import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types' @@ -45,7 +46,7 @@ export default function MyAnalyticsPage() { if (loading) { return (
- +
) } diff --git a/frontend/src/pages/MySharesPage.tsx b/frontend/src/pages/MySharesPage.tsx index 48062682..17ea9a4a 100644 --- a/frontend/src/pages/MySharesPage.tsx +++ b/frontend/src/pages/MySharesPage.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useCallback } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { sessionsApi } from '@/api/sessions' @@ -47,6 +50,7 @@ export default function MySharesPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [copiedId, setCopiedId] = useState(null) + const [revokeTarget, setRevokeTarget] = useState(null) const fetchShares = useCallback(async () => { try { @@ -77,18 +81,16 @@ export default function MySharesPage() { } } - const handleRevoke = async (share: SessionShare) => { - const confirmed = window.confirm( - 'Revoke this share link? Anyone with the link will no longer be able to access the session.' - ) - if (!confirmed) return - + const handleRevoke = async () => { + if (!revokeTarget) return try { - await sessionsApi.revokeShare(share.id) - setShares((prev) => prev.filter((s) => s.id !== share.id)) + await sessionsApi.revokeShare(revokeTarget.id) + setShares((prev) => prev.filter((s) => s.id !== revokeTarget.id)) toast.success('Share link revoked') } catch { toast.error('Failed to revoke share link') + } finally { + setRevokeTarget(null) } } @@ -96,7 +98,7 @@ export default function MySharesPage() { if (loading) { return (
-
+
) } @@ -139,18 +141,20 @@ export default function MySharesPage() { {/* Empty state */} {shares.length === 0 ? ( -
- -

No shared sessions

-

- Share a session from the session detail page to create a link -

- +
+ } + title="No shared sessions" + description="Share a session from the session detail page to create a link" + action={ + + } + />
) : (
@@ -223,7 +227,7 @@ export default function MySharesPage() {
)} + + setRevokeTarget(null)} + onConfirm={handleRevoke} + title="Revoke Share Link" + message="Revoke this share link? Anyone with the link will no longer be able to access the session." + confirmLabel="Revoke" + />
) } diff --git a/frontend/src/pages/MyTreesPage.tsx b/frontend/src/pages/MyTreesPage.tsx index 04422db4..ce3446da 100644 --- a/frontend/src/pages/MyTreesPage.tsx +++ b/frontend/src/pages/MyTreesPage.tsx @@ -7,6 +7,7 @@ import type { TreeListItem } from '@/types' import { TagBadges } from '@/components/common/TagBadges' import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ShareTreeModal } from '@/components/library/ShareTreeModal' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' @@ -177,7 +178,7 @@ export function MyTreesPage() { {/* Loading State */} {isLoading ? (
-
+
) : trees.length === 0 ? (
diff --git a/frontend/src/pages/ProceduralEditorPage.tsx b/frontend/src/pages/ProceduralEditorPage.tsx index 67fbbec7..a9a74560 100644 --- a/frontend/src/pages/ProceduralEditorPage.tsx +++ b/frontend/src/pages/ProceduralEditorPage.tsx @@ -9,6 +9,7 @@ import { MaintenanceScheduleSection } from '@/components/procedural-editor/Maint import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils' import { StepList } from '@/components/procedural-editor/StepList' import { TagInput } from '@/components/common/TagInput' +import { Spinner } from '@/components/common/Spinner' import { toast } from '@/lib/toast' import type { TreeType, MaintenanceSchedule, TargetList } from '@/types' @@ -143,7 +144,7 @@ export function ProceduralEditorPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index c9f23905..4d313ace 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -10,6 +10,7 @@ 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 { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { StepFeedback } from '@/components/session/StepFeedback' @@ -290,7 +291,7 @@ export function ProceduralNavigationPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index d6bc7785..dd4d8d9a 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -13,6 +13,7 @@ import type { MenuAction } from '@/components/common/ActionMenu' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types' import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' @@ -289,7 +290,7 @@ export function SessionDetailPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 7fb500ee..50ed13ca 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -6,6 +6,8 @@ import type { Session, TreeListItem } from '@/types' import type { DateRange } from 'react-day-picker' import { SessionFilters } from '@/components/session/SessionFilters' import type { SessionFilterState } from '@/components/session/SessionFilters' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { getSessionResumePath } from '@/lib/routing' @@ -188,27 +190,22 @@ export function SessionHistoryPage() { {/* Loading State */} {isLoading ? (
-
+
) : sessions.length === 0 ? ( -
- No sessions found.{' '} - {filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from ? ( - - ) : ( - - )} -
+ + Clear all filters + + ) : undefined + } + /> ) : (
{sessions.map((session) => ( diff --git a/frontend/src/pages/SharedSessionPage.tsx b/frontend/src/pages/SharedSessionPage.tsx index 10e518b1..576432bb 100644 --- a/frontend/src/pages/SharedSessionPage.tsx +++ b/frontend/src/pages/SharedSessionPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' -import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react' +import { Globe, Users, ShieldAlert, FileX, Clock } from 'lucide-react' import { isAxiosError } from 'axios' import { sessionsApi } from '@/api/sessions' +import { Spinner } from '@/components/common/Spinner' import { BrandLogo } from '@/components/common/BrandLogo' import { SessionTimeline } from '@/components/session/SessionTimeline' import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview' @@ -144,7 +145,7 @@ export function SharedSessionPage() { return (
- +

Loading shared session...

diff --git a/frontend/src/pages/TeamAnalyticsPage.tsx b/frontend/src/pages/TeamAnalyticsPage.tsx index 2df599d5..3676af03 100644 --- a/frontend/src/pages/TeamAnalyticsPage.tsx +++ b/frontend/src/pages/TeamAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link, Navigate } from 'react-router-dom' -import { BarChart3, Loader2, Users, Target, Clock, TrendingUp } from 'lucide-react' +import { BarChart3, Users, Target, Clock, TrendingUp } from 'lucide-react' import { AreaChart, Area, @@ -10,6 +10,7 @@ import { Tooltip, ResponsiveContainer, } from 'recharts' +import { Spinner } from '@/components/common/Spinner' import { analyticsApi } from '@/api' import { usePermissions } from '@/hooks/usePermissions' import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types' @@ -51,7 +52,7 @@ export default function TeamAnalyticsPage() { if (loading) { return (
- +
) } diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 165b4306..85e886d2 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -11,6 +11,7 @@ import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { usePermissions } from '@/hooks/usePermissions' +import { Spinner } from '@/components/common/Spinner' import { cn, safeGetItem } from '@/lib/utils' import { toast } from '@/lib/toast' import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel' @@ -382,7 +383,7 @@ export function TreeEditorPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 43418c02..00f31bf8 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -11,6 +11,7 @@ import { MarkdownContent } from '@/components/ui/MarkdownContent' import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session' import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react' +import { Spinner } from '@/components/common/Spinner' import { toast } from '@/lib/toast' import { Modal } from '@/components/common/Modal' import { ShareSessionModal } from '@/components/session/ShareSessionModal' @@ -528,7 +529,7 @@ export function TreeNavigationPage() { if (isLoading) { return (
-
+
) }