diff --git a/backend/app/main.py b/backend/app/main.py index 91b34c91..01ccb65f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,8 +27,20 @@ def _configure_seed_module(mod: object, api_url: str, email: str, password: str) mod.ADMIN_PASSWORD = password # type: ignore[attr-defined] +async def _seed_users_directly() -> None: + """Seed test users directly via DB if they don't exist yet.""" + try: + from scripts.seed_test_users import main as seed_users + logger.info("[seed] Seeding test users directly via DB...") + await seed_users() + logger.info("[seed] Test users seeded!") + except Exception as e: + logger.warning(f"[seed] User seeding failed: {e}") + raise + + async def _seed_trees_background() -> None: - """Background task: seed all flows via HTTP API after server is ready.""" + """Background task: seed test users + all flows after server is ready.""" await asyncio.sleep(5) # Wait for server to be fully ready port = os.environ.get("PORT", "8000") api_url = f"http://127.0.0.1:{port}/api/v1" @@ -37,12 +49,17 @@ async def _seed_trees_background() -> None: try: import httpx - # Login to verify admin user exists + # Try to login — if it fails, seed users first async with httpx.AsyncClient(base_url=api_url, timeout=30) as client: login_resp = await client.post("/auth/login/json", json={"email": email, "password": password}) if login_resp.status_code != 200: - logger.warning("[seed] Could not login as admin — skipping flow seeding") - return + logger.warning("[seed] Admin login failed — seeding users first") + await _seed_users_directly() + # Retry login after seeding users + login_resp = await client.post("/auth/login/json", json={"email": email, "password": password}) + if login_resp.status_code != 200: + logger.error(f"[seed] Admin login still failing after user seed (status={login_resp.status_code}) — aborting") + return token = login_resp.json()["access_token"] # Check if trees already exist diff --git a/docs/plans/ResolutionFlow_UX_Deep_Dive_Final_Plan.md b/docs/plans/ResolutionFlow_UX_Deep_Dive_Final_Plan.md new file mode 100644 index 00000000..b20701b8 --- /dev/null +++ b/docs/plans/ResolutionFlow_UX_Deep_Dive_Final_Plan.md @@ -0,0 +1,441 @@ +# ResolutionFlow UX Deep Dive — Final Merged Implementation Plan + +**Date:** 2026-02-19 +**Author:** Michael Chihlas +**Purpose:** Comprehensive frontend UX sweep merging the original 35-issue audit with Codex-revised plan. Ordered by user impact, covering broken functionality, navigation, shared components, visual consistency, and backend alignment. + +--- + +## Locked Decisions + +These decisions are finalized and should not be revisited: + +1. **Canonical post-editor/post-session destination:** `/trees` (the full flow library). `/my-trees` remains available for creator-specific management but is not a navigation target from editors or sessions. +2. **Step Library handling:** Add a placeholder route now. Not hidden, not fully built. +3. **Cleanup strategy:** Immediate removal of dead code and unused types. No staged deprecation. One final `grep` sweep before each deletion to confirm zero references. + +--- + +## Phase 0 — Tree Editor Authoring Blockers (Immediate) + +**Goal:** Remove "can't reach bottom / too busy form" friction before the broader UX sweep. This phase was absent from the original audit and added by the Codex review. + +### 0.1 Fix node editor scroll trap and bottom clipping + +**File:** `NodeEditorPanel.tsx` + +- Replace fixed viewport math (`h-[calc(100vh-105px)]`) with `h-full min-h-0` +- Keep body as the only scroll container (`min-h-0 flex-1 overflow-y-auto`) +- Make footer sticky (`sticky bottom-0`) so Save/Cancel are always reachable +- Add `scroll-pb-24` on form body to prevent bottom fields hiding behind footer + +### 0.2 Reduce instruction density in decision/action/resolution forms + +**Files:** `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` + +- Convert long instructional copy to compact labels + InfoTip tooltips +- Keep one short contextual hint per form section +- Remove large always-visible prose blocks + +### 0.3 Keep answer-first branching flow explicit + +**File:** `NodeEditorPanel.tsx` + +- Preserve existing behavior: saving decision options without `next_node_id` auto-creates answer stubs +- Add UI hint in decision form: "Options become answer placeholders you type later." + +### Phase 0 Verification + +- Open a long decision form → verify full vertical scroll to footer fields/buttons +- Focus bottom fields → confirm they are visible (not clipped behind footer) +- Decision instructions are compact with info tooltips (no walls of text) +- Save a decision with two new options → two answer placeholders are auto-created + +--- + +## Phase 1 — Broken Functionality (Critical) + +**Goal:** Fix features that are actively broken or silently wrong right now. + +### 1.1 Register /step-library route + +Sidebar links to `/step-library` but no route exists — users hit a blank page. + +**Changes:** + +- `router.tsx` — add route with lazy-loaded `StepLibraryPage.tsx` placeholder shell +- `StepLibraryPage.tsx` — create placeholder page (e.g., "Step Library — Coming Soon" with consistent layout) +- `Sidebar.tsx` — remove `badge="dot"` while placeholder is live (no false "new feature" signal) + +### 1.2 Preserve backend auth error detail in login/register flows + +Login failures show "Request failed with status code 401" instead of "Invalid credentials." + +**File:** `authStore.ts` (lines ~50-54, 63-67) + +- Extract `error.response?.data?.detail` before falling back to generic message +- Apply to both login and register error paths + +### 1.3 Fix inverted 4xx toast logic and add 429 handling + +**File:** `client.ts` (lines ~36-43) + +4xx errors with a `detail` message currently suppress the toast entirely (inverted condition). No 429 handler exists. + +**Behavior after fix:** + +| Status | Action | +|--------|--------| +| 401 | Suppressed (handled by token refresh flow) | +| 429 | Always toast: backend detail or "Rate limit exceeded, please retry shortly." | +| Other 4xx | Toast `detail` if present, else "Invalid request." | +| 5xx | Existing generic server error toast (unchanged) | + +### 1.4 Fix role update payload contract mismatch + +**File:** `accounts.ts` (line ~28) + +Sends `{ role }` but backend schema expects `{ account_role }`. Role changes silently fail with 422. + +- Change to `{ account_role: role }` + +### 1.5 Fix "Repeat Last Session" broken for non-troubleshooting flows + +**File:** `TreeLibraryPage.tsx` (line ~453) + +Currently hardcodes `/trees/:id/navigate` — loses prefill state via safety redirect for procedural/maintenance flows. + +- Use `getSessionResumePath()` instead of hardcoded path + +### Phase 1 Verification + +- Step Library nav opens placeholder page from Sidebar, AppLayout mobile nav, and Quick Launch +- Login with bad credentials shows backend detail string (e.g., "Invalid credentials"), not axios error +- Force a 429 response and verify toast appears +- Change a team member's role in Account Settings → confirm it actually saves (requires `team_admin` user) +- Repeat Last Session works correctly for troubleshooting, procedural, and maintenance flow types + +--- + +## Phase 2 — Navigation Correctness (Medium Scope) + +**Goal:** Fix cases where users end up at unexpected pages or lack navigation affordances. + +### 2.1 Standardize editor/session back and exit targets to /trees + +**Files:** + +- `ProceduralEditorPage.tsx` (lines ~86, 92, 157) — change `/my-trees` → `/trees` +- `ProceduralNavigationPage.tsx` (lines ~120, 180, 304, 330) — error, cancel, completion, and intake cancel paths all → `/trees` + +### 2.2 Add explicit exit affordance during procedural session execution + +Currently there is no way to leave a procedural session mid-execution except the browser back button. + +**File:** `ProceduralNavigationPage.tsx` (top bar, ~line 345) + +- Add an Exit button to the top bar +- If session has progress, exit opens a `ConfirmDialog` before navigating to `/trees` +- If no progress, navigate directly + +### 2.3 Remove duplicate account links in global nav + +"Team" and "Settings" both point to `/account`. Same duplication in TopBar dropdown. + +**Files:** + +- `Sidebar.tsx` (lines ~206-207) — consolidate footer to single "Account" item +- `TopBar.tsx` — consolidate dropdown to single "Account" entry + +### 2.4 Improve analytics routing for non-owners + +Non-owners click Analytics, hit an access-denied wall, then must click through to personal stats. + +**File:** `TeamAnalyticsPage.tsx` (lines ~48-65) + +- Auto-redirect non-owner/non-admin users to `/analytics/me` +- Show an informational toast explaining the redirect (e.g., "Viewing your personal analytics") + +### 2.5 Add feedback before permission redirects in tree editor + +`TreeEditorPage` silently redirects when users lack permission — no feedback shown. + +**File:** `TreeEditorPage.tsx` (lines ~143-146, 157-159) + +- Add `toast.error("You don't have permission to edit this flow")` before each `navigate()` call + +### 2.6 Delete orphaned AdminCategoriesPage + +Not connected to any route, superseded by `admin/GlobalCategoriesPage.tsx`. + +- Delete `AdminCategoriesPage.tsx` +- Remove its export from `index.ts` if present + +### Phase 2 Verification + +- Procedural editor Back button → `/trees` (not `/my-trees`) +- Procedural session cancel, exit, error, and completion → all route to `/trees` +- Exit button is visible during procedural execution and prompts confirmation if session has progress +- Sidebar footer shows one "Account" item (not "Team" + "Settings") +- Non-owner clicks Analytics → auto-redirects to `/analytics/me` with toast +- Unauthorized tree edit attempt → toast shown, then redirect + +--- + +## Phase 3 — Shared Components & Quick Consistency Wins (Medium Scope) + +**Goal:** Build reusable infrastructure and fix small but important consistency issues. + +### 3.1 Create shared Spinner component + +Currently 4 different spinner implementations across 20+ files. + +**Create:** `Spinner.tsx` with `sm | md | lg` sizes, default `border-t-primary` + +**Migrate page-level loading states in:** + +- `ProceduralNavigationPage.tsx` +- `ProceduralEditorPage.tsx` +- `TreeEditorPage.tsx` +- `TreeNavigationPage.tsx` +- `SessionHistoryPage.tsx` +- `SessionDetailPage.tsx` +- `MySharesPage.tsx` +- `MyTreesPage.tsx` +- `AccountSettingsPage.tsx` +- `SharedSessionPage.tsx` +- `PageLoader.tsx` + +**Deferred:** Leave tiny inline button spinners for later to avoid churn. + +### 3.2 Promote EmptyState to shared component + +Admin has a well-designed EmptyState; main app uses 3+ ad hoc patterns. + +- Move/create `common/EmptyState.tsx` +- Re-export from admin's `EmptyState.tsx` for backward compatibility +- Adopt in: `MySharesPage.tsx`, `SessionHistoryPage.tsx`, `TreeLibraryPage.tsx` + +### 3.3 Replace native window.confirm() with design-system ConfirmDialog + +3 places use native browser dialogs that break the design system. + +**Files:** + +- `MySharesPage.tsx` (line ~81) +- `NodeEditorPanel.tsx` (line ~87) +- `FolderSidebar.tsx` (line ~286) + +### 3.4 Fix sidebar optimistic unpin bug + +State is removed immediately even if API call fails — the flow disappears permanently until page refresh. + +**File:** `Sidebar.tsx` (lines ~105-113) + +- Move `setPinnedFlows` update to after successful `await` (not before) + +### 3.5 Fix PinnedFlow.tree_type missing 'maintenance' + +Maintenance flows can be pinned but navigation will use the wrong path. + +**Files:** + +- `pinnedFlows.ts` (line ~7) — add `'maintenance'` to the `PinnedFlow.tree_type` union type +- `PinnedFlowsSection.tsx` — validate icon/path logic via `getTreeNavigatePath` handles maintenance correctly + +### Phase 3 Verification + +- All page-level loading states use the shared `Spinner` component (visual consistency) +- Empty states in MyShares, SessionHistory, TreeLibrary use the shared `EmptyState` component +- Deleting a share, removing a node, removing a folder → all show styled dialog (not browser native) +- Unpin a flow while network is down → flow should NOT disappear (reverts on failure) +- Pin a maintenance flow → clicking it navigates to the correct path + +--- + +## Phase 4 — Visual Consistency Sweep (Large Scope — Many Small Changes) + +**Goal:** Design system compliance fixes. Each change is small but there are many files. + +### 4.1 Fix Sonner toast styling + +The `richColors` prop overrides custom CSS with garish built-in green/red backgrounds. Existing custom CSS in `index.css` (lines ~201-251) already defines themed toasts (`bg-card`, `border-border`, colored border accents) but `richColors` overrides them. + +**File:** `main.tsx` + +- Remove `richColors` prop +- Add `visibleToasts={3}` and `gap={8}` +- Keep existing custom toast CSS in `index.css`; patch only if needed to ensure themed styles fully apply + +### 4.2 Typography: Add font-heading to all page H1s + +Missing from approximately half the pages. + +**Files to update:** + +- `MyTreesPage.tsx` +- `TeamAnalyticsPage.tsx` +- `MyAnalyticsPage.tsx` +- `FeedbackPage.tsx` +- `AccountSettingsPage.tsx` +- `TeamCategoriesPage.tsx` +- `admin/PageHeader.tsx` + +### 4.3 Typography: Add font-label to TagBadges component + +Design system requires Outfit font for tags/badges. + +**File:** `TagBadges.tsx` + +### 4.4 Fix hardcoded light-mode button in TeamAnalyticsPage + +Only hardcoded `bg-white text-black` button in the app — breaks in dark mode. + +**File:** `TeamAnalyticsPage.tsx` (line ~59) + +- Replace with `bg-gradient-brand text-white` + +### 4.5 Fix non-standard focus ring tokens + +Analytics selects use `focus:ring-ring` instead of the standard token. + +**Files:** `TeamAnalyticsPage.tsx`, `MyAnalyticsPage.tsx` + +- Change `focus:ring-ring` → `focus:ring-primary/20` + +### 4.6 Replace deprecated glass-stat style + +**File:** `AccountSettingsPage.tsx` (line ~588) + +- Replace `glass-stat` with `bg-card border border-border` + +### 4.7 Standardize container/padding on analytics pages + +Missing responsive padding. + +**Files:** `TeamAnalyticsPage.tsx`, `MyAnalyticsPage.tsx` + +- Add `container mx-auto px-4 py-6 sm:px-6 sm:py-8` + +### Phase 4 Verification + +- Toast colors/borders follow custom theme (not Sonner rich presets) — check success, error, and info toasts +- All page H1s use `font-heading` (Plus Jakarta Sans) +- Tag badges use `font-label` (Outfit) +- TeamAnalytics CTA button renders correctly in both light and dark mode +- Focus rings on analytics selects use subtle `primary/20` glow +- No `glass-stat` class remains in the codebase +- Analytics pages have consistent container spacing matching rest of app + +--- + +## Phase 5 — Backend Alignment & Cleanup (Immediate Removals) + +**Goal:** API contract fixes and dead code removal. All removals are immediate (per locked decision #3), with a final `grep` sweep before each deletion. + +### 5.1 Remove non-functional drafts toggle from library UI + +Backend has no `include_drafts` parameter — the toggle does nothing. + +**Files:** + +- `TreeLibraryPage.tsx` — remove `showDrafts` state and drafts filter UI +- `tree.ts` (`TreeFilters` type) — remove `include_drafts` field + +### 5.2 Align invite types with backend schema + +`invited_by_id` and `accepted_by_id` not in backend response — always `undefined`. + +**File:** `account.ts` — remove `invited_by_id` and `accepted_by_id` from `AccountInvite` type + +### 5.3 Remove dead/unused client code + +Before deleting each item, run a global `grep` to confirm zero consumers: + +| Item | File | What to Remove | +|------|------|----------------| +| `pinnedFlowsApi.pin()` | `pinnedFlows.ts` | Dead method (never called) | +| `pinnedFlowsApi.reorder()` | `pinnedFlows.ts` | Dead method (never called) | +| `treesApi.getSharedTree()` | `trees.ts` | Dead method (no route/consumer) | +| `SessionListResponse` | `sessions.ts` | Unused type | +| `RatingCreate.is_verified_use` | `step.ts` | Field ignored by backend | +| `AdminCategoriesPage.tsx` | — | Orphaned file + export (if not already deleted in Phase 2) | + +### 5.4 Add session list truncation indicator + +Session history silently truncates at the backend limit with no indication to the user. + +**File:** `SessionHistoryPage.tsx` + +- Request `size=51` from backend +- If result length is 51: show "Showing first 50 sessions" indicator, render only first 50 +- If result length ≤ 50: show "Showing X sessions" +- No backend API contract changes required (frontend lookahead strategy) + +### Phase 5 Verification + +- No drafts toggle visible in TreeLibrary UI +- `grep -r "include_drafts" frontend/src/` returns zero results +- `grep -r "invited_by_id\|accepted_by_id" frontend/src/` returns zero results for `AccountInvite` usage +- `grep` for each removed method/type confirms zero references +- Session history shows truncation indicator at 50+ results +- Session history shows "Showing X sessions" at fewer than 50 results +- `cd frontend && npm run build` passes with zero errors + +--- + +## Items Explicitly Deferred + +| Item | Reason | +|------|--------| +| Migrate 14+ custom modals to shared Modal component | Very high effort, low breakage risk. Migrate incrementally on new work. | +| Standardize input border radius app-wide | Cosmetic, low user impact | +| Standardize icon sizing method (`className` vs `size` prop) | No visual difference | +| Session pagination (full load-more) | Larger feature, beyond UX sweep scope | +| Inline button spinners | Leave for later to avoid churn (Phase 3 note) | +| Full Step Library feature build | Intentionally placeholder-only this cycle | + +--- + +## Public APIs / Interfaces / Types Changed + +**Frontend routing:** + +- Add new route `/step-library` in `router.tsx` + +**Type/interface updates:** + +- `TreeFilters` in `tree.ts`: remove `include_drafts` +- `AccountInvite` in `account.ts`: remove `invited_by_id`, `accepted_by_id` +- `PinnedFlow.tree_type` in `pinnedFlows.ts`: add `'maintenance'` +- `RatingCreate` in `step.ts`: remove `is_verified_use` +- Remove unused `SessionListResponse` in `sessions.ts` + +**Shared component surface:** + +- Add `Spinner` component in `Spinner.tsx` +- Add common `EmptyState` component in `EmptyState.tsx` + +--- + +## Cross-Phase Verification Checklist + +Run after each phase is complete: + +1. **Build:** `cd frontend && npm run build` — must pass with zero errors +2. **Navigation:** Test all sidebar items, back buttons, editor → library flow, procedural navigation exit +3. **Auth:** Intentional wrong password → shows backend error detail +4. **Role change:** Test in Account Settings (requires `team_admin` user) +5. **Visual spot-check:** H1 fonts, spinner consistency, empty states, toast styling, dark mode +6. **Grep sweep:** Before merging each phase, confirm no dead references remain for removed items + +--- + +## Assumptions + +- `/trees` is the canonical destination for broad flow browsing and all post-task returns +- `/my-trees` remains available but is not a default navigation target +- Step Library is placeholder-only this cycle — no full feature work +- No backend API contract changes are required for any item in this plan +- Session truncation uses the frontend lookahead strategy (`size=51`) +- All cleanup is immediate with pre-deletion reference verification diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts index 943d4481..3e1e3b04 100644 --- a/frontend/src/api/accounts.ts +++ b/frontend/src/api/accounts.ts @@ -25,7 +25,7 @@ export const accountsApi = { async updateMemberRole(userId: string, role: string): Promise { const response = await apiClient.patch( `/accounts/me/members/${userId}/role`, - { role } + { account_role: role } ) return response.data }, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cf57d557..07b6bd36 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -25,20 +25,28 @@ function handleGlobalError(error: AxiosError) { } const status = error.response.status - const data = error.response.data as { detail?: string } + const data = error.response.data as { detail?: string | unknown[] } + + // Extract a displayable error message from the response. + // FastAPI returns `detail` as a string for most errors, but 422 validation + // errors return an array of objects — we must not pass those to toast. + const detail = typeof data?.detail === 'string' ? data.detail : undefined // Don't show toast for 401 (handled by refresh interceptor) if (status === 401) { return } - // Client errors (4xx) + // Rate limit — always worth notifying + if (status === 429) { + toast.error(detail || 'Too many requests — please try again shortly') + return + } + + // Client errors (4xx) — don't toast globally. + // Pages handle their own 4xx errors (permission checks, validation, not-found) + // and many are caught silently. Global toasts here cause noisy duplicates. if (status >= 400 && status < 500) { - const message = data?.detail || 'Invalid request' - // Only show generic messages - pages handle specific errors - if (!data?.detail) { - toast.error(message) - } return } diff --git a/frontend/src/api/pinnedFlows.ts b/frontend/src/api/pinnedFlows.ts index 41b3754a..38823178 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 @@ -22,19 +22,9 @@ export const pinnedFlowsApi = { return data }, - pin: async (treeId: string): Promise => { - const { data } = await apiClient.post(`/trees/${treeId}/pin`) - return data - }, - unpin: async (treeId: string): Promise => { await apiClient.delete(`/trees/${treeId}/pin`) }, - - reorder: async (order: { tree_id: string; display_order: number }[]): Promise => { - const { data } = await apiClient.patch('/trees/pinned/reorder', { order }) - return data - }, } export default pinnedFlowsApi diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 635d593a..ffda6080 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -15,14 +15,6 @@ export interface SessionListParams { completed_before?: string } -export interface SessionListResponse { - items: Session[] - total: number - page: number - size: number - pages: number -} - export const sessionsApi = { async list(params?: SessionListParams): Promise { const response = await apiClient.get('/sessions', { params }) diff --git a/frontend/src/api/trees.ts b/frontend/src/api/trees.ts index f8c8375a..8b21840c 100644 --- a/frontend/src/api/trees.ts +++ b/frontend/src/api/trees.ts @@ -1,5 +1,5 @@ import apiClient from './client' -import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters, TreeShareCreate, TreeShare, TreeVisibilityUpdate, SharedTree, TreeValidationResponse } from '@/types' +import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters, TreeShareCreate, TreeShare, TreeVisibilityUpdate, TreeValidationResponse } from '@/types' export const treesApi = { async list(params?: TreeFilters): Promise { @@ -60,11 +60,6 @@ export const treesApi = { return response.data }, - async getSharedTree(shareToken: string): Promise { - const response = await apiClient.get(`/shared/${shareToken}`) - return response.data - }, - // Tree validation async canPublish(id: string): Promise { const response = await apiClient.post(`/trees/${id}/can-publish`) 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/admin/PageHeader.tsx b/frontend/src/components/admin/PageHeader.tsx index a388f00f..d282bcdc 100644 --- a/frontend/src/components/admin/PageHeader.tsx +++ b/frontend/src/components/admin/PageHeader.tsx @@ -12,7 +12,7 @@ export function PageHeader({ title, description, action, className }: PageHeader return (
-

{title}

+

{title}

{description && (

{description}

)} 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..04c54daa 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/RouteError.tsx b/frontend/src/components/common/RouteError.tsx index 0fa4e7e9..e06c3082 100644 --- a/frontend/src/components/common/RouteError.tsx +++ b/frontend/src/components/common/RouteError.tsx @@ -17,7 +17,7 @@ export function RouteError() { } return ( -
+

Oops!

{errorMessage}

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/common/TagBadges.tsx b/frontend/src/components/common/TagBadges.tsx index bd93cbc6..95d850b2 100644 --- a/frontend/src/components/common/TagBadges.tsx +++ b/frontend/src/components/common/TagBadges.tsx @@ -34,7 +34,7 @@ export function TagBadges({ }} disabled={!onTagClick} className={cn( - 'rounded-full transition-colors', + 'rounded-full font-label transition-colors', size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm', variant === 'default' ? 'bg-accent text-muted-foreground hover:bg-accent' @@ -48,7 +48,7 @@ export function TagBadges({ {hiddenCount > 0 && ( s.sidebarCollapsed) const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar) - const [categories, setCategories] = useState([]) - const [tags, setTags] = useState([]) - const [activeCategoryId, setActiveCategoryId] = useState(null) - const [activeTags, setActiveTags] = useState([]) const [activeSessionCount, setActiveSessionCount] = useState(0) const [pinnedFlows, setPinnedFlows] = useState([]) const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 }) @@ -35,20 +21,11 @@ export function Sidebar() { useEffect(() => { const fetchData = async () => { try { - const [cats, tagList, activeSessions, allTrees, pinnedData] = await Promise.all([ - categoriesApi.list(), - tagsApi.list().catch(() => []), + const [activeSessions, allTrees, pinnedData] = await Promise.all([ sessionsApi.list({ completed: false, size: 50 }).catch(() => []), treesApi.list({ sort_by: 'name' }).catch(() => []), pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })), ]) - setCategories(cats.map(c => ({ - id: c.id, - name: c.name, - color: c.color || '#3b82f6', - count: c.tree_count || 0, - }))) - setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15)) setActiveSessionCount(activeSessions.length) setPinnedFlows(pinnedData.items) @@ -64,44 +41,6 @@ export function Sidebar() { fetchData() }, []) - const navigate = useNavigate() - const location = useLocation() - - // Sync active filters from URL when on /trees page - useEffect(() => { - if (location.pathname === '/trees') { - const params = new URLSearchParams(location.search) - setActiveCategoryId(params.get('category') || null) - const tagsParam = params.get('tags') - setActiveTags(tagsParam ? tagsParam.split(',') : []) - } - }, [location.pathname, location.search]) - - const handleCategorySelect = (id: string | null) => { - setActiveCategoryId(id) - const params = new URLSearchParams(location.search) - if (id) { - params.set('category', id) - } else { - params.delete('category') - } - navigate(`/trees?${params.toString()}`) - } - - const handleTagClick = (tag: string) => { - const next = activeTags.includes(tag) - ? activeTags.filter(t => t !== tag) - : [...activeTags, tag] - setActiveTags(next) - const params = new URLSearchParams(location.search) - if (next.length > 0) { - params.set('tags', next.join(',')) - } else { - params.delete('tags') - } - navigate(`/trees?${params.toString()}`) - } - const handleUnpin = async (treeId: string) => { try { await pinnedFlowsApi.unpin(treeId) @@ -172,27 +111,13 @@ export function Sidebar() { - +
- -
- - {/* Categories */} - - -
- - {/* Tags */} - )} - {/* Spacer */} + {/* Spacer — pushes footer to bottom */}
{/* Footer */} @@ -203,8 +128,7 @@ export function Sidebar() { {!sidebarCollapsed && ( <> - - + )}
- 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" - > - - Account - 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 + Account {isSuperAdmin && ( (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 ba085d28..a6d76349 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,16 +37,18 @@ 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 + // Initialize/reset draft when nodeId changes or when node type changes + // (e.g., answer stub → decision/action/solution via type picker) useEffect(() => { if (node) { setDraft(cloneWithoutChildren(node)) setIsDirty(false) setShowDeleteConfirm(false) } - }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps + }, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps const handleDraftUpdate = useCallback((updates: Partial) => { setDraft(prev => prev ? { ...prev, ...updates } : prev) @@ -58,7 +61,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan updateNode(nodeId, draftWithoutChildren) // Auto-create answer stubs for new decision options without next_node_id - if (draft.options) { + if (draft.type === 'decision' && draft.options) { const options = draft.options.filter(o => o.label.trim()) const stubsCreated: Array<{ optId: string; stubId: string }> = [] @@ -79,12 +82,20 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan } } + // Auto-create answer stub for action node without next_node_id + if (draft.type === 'action' && !draft.next_node_id) { + const stubId = addNode(nodeId, 'answer') + updateNode(stubId, { title: 'Next Step' }) + updateNode(nodeId, { next_node_id: stubId }) + } + setIsDirty(false) }, [draft, node, nodeId, updateNode, addNode]) const handleClose = useCallback(() => { if (isDirty) { - if (!window.confirm('You have unsaved changes. Discard them?')) return + setShowDiscardConfirm(true) + return } onClose() }, [isDirty, onClose]) @@ -162,7 +173,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const isRoot = treeStructure?.id === nodeId return ( -
+
{/* Header */}
@@ -175,14 +186,14 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
{/* Body — scrollable form area */} -
+
{draft.type === 'decision' && } {draft.type === 'action' && } {draft.type === 'solution' && }
{/* Footer */} -
+
+ + setShowDiscardConfirm(false)} + onConfirm={() => { + setShowDiscardConfirm(false) + onClose() + }} + title="Discard Changes" + message="You have unsaved changes. Discard them?" + confirmLabel="Discard" + />
) } diff --git a/frontend/src/components/tree-editor/NodeFormAction.tsx b/frontend/src/components/tree-editor/NodeFormAction.tsx index 90fcd1cf..2cd5b837 100644 --- a/frontend/src/components/tree-editor/NodeFormAction.tsx +++ b/frontend/src/components/tree-editor/NodeFormAction.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { DynamicArrayField } from './DynamicArrayField' -import { NodePicker } from './NodePicker' import { useTreeEditorStore } from '@/store/treeEditorStore' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { InfoTip } from '@/components/common/InfoTip' @@ -20,9 +19,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) { e => e.nodeId === node.id && e.field === 'title' ) - const nextNodeError = validationErrors.find( - e => e.nodeId === node.id && e.field === 'next_node_id' - ) + const hasNextNode = !!node.next_node_id const handleAddCommand = () => { onUpdate({ @@ -161,16 +158,16 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) { />
- {/* Next Node */} - onUpdate({ next_node_id: nodeId })} - parentNodeId={node.id} - excludeNodeId={node.id} - label="Next Node (after action)" - placeholder="Select or create next node..." - error={nextNodeError?.message} - /> + {/* Next step hint */} + {hasNextNode ? ( +

+ Next step is linked — click it on the canvas to edit. +

+ ) : ( +

+ Save to create a placeholder for the next step. +

+ )}
) } diff --git a/frontend/src/components/tree-editor/NodeFormDecision.tsx b/frontend/src/components/tree-editor/NodeFormDecision.tsx index c9570167..3d20ae98 100644 --- a/frontend/src/components/tree-editor/NodeFormDecision.tsx +++ b/frontend/src/components/tree-editor/NodeFormDecision.tsx @@ -82,21 +82,10 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
{/* Root node banner */} {isRootNode && ( -
-
-
- -
-
-

- Starting Question -

-

- This is the first question users will see when they start this troubleshooting tree. - Each option below creates a different troubleshooting path. -

-
-
+
+ + Starting Question +
)} @@ -150,6 +139,7 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) { ? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path." : "Each option can branch to a different next step."} /> +

Options become answer placeholders you can fill in later.

{optionsError && (

{optionsError.message}

)} diff --git a/frontend/src/components/tree-editor/NodeFormResolution.tsx b/frontend/src/components/tree-editor/NodeFormResolution.tsx index 2e1503e1..77d3ff79 100644 --- a/frontend/src/components/tree-editor/NodeFormResolution.tsx +++ b/frontend/src/components/tree-editor/NodeFormResolution.tsx @@ -143,10 +143,9 @@ Document what was done and the outcome.
{/* Note about terminal node */} -
- Note: Solution nodes are terminal - they end the troubleshooting flow. - The session will be marked complete when the user reaches this node. -
+

+ Solution nodes are terminal — the session completes when users reach this node. +

) } diff --git a/frontend/src/index.css b/frontend/src/index.css index e521169c..06bc9aee 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -198,59 +198,61 @@ } } -/* Sonner Toast Customization */ +/* Sonner Toast Customization — outside @layer for higher specificity */ +[data-sonner-toast] { + background-color: hsl(var(--card)) !important; + color: hsl(var(--card-foreground)) !important; + border: 1px solid hsl(var(--border)) !important; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3) !important; + border-radius: 0.75rem; + font-family: 'Inter', system-ui, sans-serif; +} + +[data-sonner-toast] [data-title] { + font-family: 'Inter', system-ui, sans-serif; + font-weight: 600; +} + +[data-sonner-toast][data-type="success"] { + border-color: rgba(52, 211, 153, 0.3) !important; +} +[data-sonner-toast][data-type="success"] [data-icon] { + color: #34d399; +} + +[data-sonner-toast][data-type="error"] { + border-color: rgba(248, 113, 113, 0.3) !important; +} +[data-sonner-toast][data-type="error"] [data-icon] { + color: #f87171; +} + +[data-sonner-toast][data-type="info"] { + border-color: hsl(var(--border)) !important; +} +[data-sonner-toast][data-type="info"] [data-icon] { + color: hsl(var(--muted-foreground)); +} + +[data-sonner-toast][data-type="warning"] { + border-color: rgba(251, 191, 36, 0.3) !important; +} +[data-sonner-toast][data-type="warning"] [data-icon] { + color: #fbbf24; +} + +[data-sonner-toast] [data-close-button] { + color: hsl(var(--muted-foreground)); + border-radius: 0.375rem; + transition: color 150ms, background-color 150ms; +} +[data-sonner-toast] [data-close-button]:hover { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +/* React Day Picker Customization */ @layer components { - :where([data-sonner-toast]) { - @apply bg-card text-card-foreground; - @apply border border-border shadow-lg; - @apply rounded-xl; - font-family: 'Inter', system-ui, sans-serif; - backdrop-filter: blur(10px); - } - - :where([data-sonner-toast]) [data-title] { - font-family: 'Inter', system-ui, sans-serif; - font-weight: 600; - } - - :where([data-sonner-toast][data-type="success"]) { - border-color: rgba(52, 211, 153, 0.3); - } - :where([data-sonner-toast][data-type="success"]) [data-icon] { - color: #34d399; - } - - :where([data-sonner-toast][data-type="error"]) { - border-color: rgba(248, 113, 113, 0.3); - } - :where([data-sonner-toast][data-type="error"]) [data-icon] { - color: #f87171; - } - - :where([data-sonner-toast][data-type="info"]) { - @apply border-border; - } - :where([data-sonner-toast][data-type="info"]) [data-icon] { - @apply text-muted-foreground; - } - - :where([data-sonner-toast][data-type="warning"]) { - border-color: rgba(251, 191, 36, 0.3); - } - :where([data-sonner-toast][data-type="warning"]) [data-icon] { - color: #fbbf24; - } - - :where([data-sonner-toast]) [data-close-button] { - @apply text-muted-foreground hover:bg-accent hover:text-accent-foreground; - @apply rounded-md transition-colors; - } - - :where([data-sonner-toast]) [data-icon][data-loading] { - @apply text-white; - } - - /* React Day Picker Customization */ .rdp-custom { @apply text-foreground; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b739ee18..bdb2d697 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,8 +10,13 @@ createRoot(document.getElementById('root')!).render( , diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index a3e0084b..6266f44c 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 (
-
+
) } @@ -155,7 +156,7 @@ export function AccountSettingsPage() {
-

Account Settings

+

Account Settings

Manage your account, subscription, and team @@ -585,7 +586,7 @@ function UsageStat({ const isAtLimit = !isUnlimited && current >= max return ( -

+

{label}

([]) - 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(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 ( -

-
-
- ) - } - - return ( -
- {/* Header */} -
-
-

- Step Categories -

-

- Manage categories for organizing step library -

-
- -
- - {/* Filter Toggle */} -
- -
- - {/* Categories List */} - {categories.length === 0 ? ( -
-

- No categories found. Create your first category to get started. -

-
- ) : ( - - c.id)} - strategy={verticalListSortingStrategy} - > -
- {categories.map(category => ( - - ))} -
-
-
- )} - - {/* Create Modal */} - setShowCreateModal(false)} - onSubmit={handleCreate} - isSaving={isSaving} - /> - - {/* Edit Modal */} - { - setShowEditModal(false) - setEditingCategory(null) - }} - onSubmit={handleEdit} - category={editingCategory} - isSaving={isSaving} - /> -
- ) -} - -export default AdminCategoriesPage diff --git a/frontend/src/pages/FeedbackPage.tsx b/frontend/src/pages/FeedbackPage.tsx index 5812f4da..b1f1ff63 100644 --- a/frontend/src/pages/FeedbackPage.tsx +++ b/frontend/src/pages/FeedbackPage.tsx @@ -135,7 +135,7 @@ export function FeedbackPage() {
-

Send Feedback

+

Send Feedback

Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts. diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index 3670d601..a7b7d88a 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 (

- +
) } @@ -62,14 +63,14 @@ export default function MyAnalyticsPage() { const outcomeBreakdown = summary.outcome_breakdown return ( -
+
{/* Header */}
-

My Analytics

+

My Analytics

@@ -84,7 +85,7 @@ export default function MyAnalyticsPage() { setPeriod(e.target.value as AnalyticsPeriod)} - className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/20" > {PERIOD_OPTIONS.map((opt) => (