fix: UX deep dive — 28 fixes across authoring, navigation, consistency, and cleanup (#86)
* fix: tree editor authoring blockers - scroll trap, form density, branching hint - Replace fixed viewport height with flex layout in NodeEditorPanel - Make footer sticky so Save/Cancel always reachable - Compact root node banner to single-line with InfoTip tooltip - Reduce resolution note from callout box to inline text - Add answer-first branching hint below options label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: broken functionality - auth errors, toast logic, role update, routing, step library - Extract backend error detail in auth store login/register - Fix inverted 4xx toast logic and add 429 rate limit handling - Send account_role field to match backend schema in role update - Use type-aware routing for Repeat Last Session button - Add step library placeholder page and route, remove dot badge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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> * refactor: shared components, ConfirmDialog migration, pinned flow fixes - Create shared Spinner component with sm/md/lg sizes - Migrate 13 page-level spinners to shared Spinner - Promote EmptyState to shared component, adopt in MyShares and SessionHistory - Replace window.confirm with ConfirmDialog in 3 files - Fix PinnedFlow.tree_type to include maintenance, update emoji display - Verify sidebar unpin handler already correct (no-op) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: visual consistency - toasts, typography, focus rings, container padding - Remove richColors from Sonner toasts, limit stacking to 3 - Add font-heading to all page H1s (7 files) - Add font-label (Outfit) to TagBadges component - Fix focus ring tokens on analytics pages - Replace deprecated glass-stat with design system tokens - Standardize container padding on analytics pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: backend alignment - remove drafts toggle, clean dead code, truncation indicator - Remove non-functional drafts toggle and clean TreeFilters type - Fix AccountInvite type to match backend schema - Remove dead API methods: pinnedFlows.pin/reorder, trees.getSharedTree - Remove unused types: SessionListResponse, RatingCreate.is_verified_use - Add session list truncation indicator with size=51 lookahead Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove bg-black from PageLoader and RouteError, fix PageLoader height PageLoader used h-screen inside a grid cell, causing it to overflow. Changed to h-full so it fits within the main-content area. Removed bg-black from both PageLoader and RouteError in favor of theme-aware bg-background to prevent black flash during lazy loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: guard against Pydantic validation error objects in toast/error messages FastAPI returns `detail` as an array of objects for 422 validation errors, not a string. Passing these objects to toast.error() or rendering them in JSX crashes React with Error #31 ("Objects are not valid as a React child"). Now checks typeof detail === 'string' before using it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: toast styling, node editor first-click, action node placeholder pattern 1. Toast fixes: Add theme="dark" to Sonner, use !important CSS overrides instead of zero-specificity :where() selectors, suppress noisy 4xx global toasts (pages handle their own errors) 2. Node editor first-click: Add node.type to draft initialization useEffect deps so draft resets when answer stub converts to real type 3. Action node redesign: Remove NodePicker dropdown, auto-create answer placeholder on save (matching decision node pattern). Users click the placeholder on canvas to choose type and fill in details. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-seed test users when release command fails on PR envs The background seeder now creates users directly via DB if login fails, instead of silently aborting. This handles Railway PR environments where the releaseCommand may not execute properly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove categories/tags from sidebar to prevent footer clipping Categories and Tags sections were pushing Feedback, Account, and Collapse off-screen when All Flows expanded its children. These filters already exist on the TreeLibraryPage, so the sidebar duplicates were removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #86.
This commit is contained in:
@@ -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
|
||||
|
||||
441
docs/plans/ResolutionFlow_UX_Deep_Dive_Final_Plan.md
Normal file
441
docs/plans/ResolutionFlow_UX_Deep_Dive_Final_Plan.md
Normal file
@@ -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
|
||||
@@ -25,7 +25,7 @@ export const accountsApi = {
|
||||
async updateMemberRole(userId: string, role: string): Promise<AccountMember> {
|
||||
const response = await apiClient.patch<AccountMember>(
|
||||
`/accounts/me/members/${userId}/role`,
|
||||
{ role }
|
||||
{ account_role: role }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PinnedFlow> => {
|
||||
const { data } = await apiClient.post(`/trees/${treeId}/pin`)
|
||||
return data
|
||||
},
|
||||
|
||||
unpin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.delete(`/trees/${treeId}/pin`)
|
||||
},
|
||||
|
||||
reorder: async (order: { tree_id: string; display_order: number }[]): Promise<PinnedFlowsResponse> => {
|
||||
const { data } = await apiClient.patch('/trees/pinned/reorder', { order })
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default pinnedFlowsApi
|
||||
|
||||
@@ -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<Session[]> {
|
||||
const response = await apiClient.get<Session[]>('/sessions', { params })
|
||||
|
||||
@@ -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<TreeListItem[]> {
|
||||
@@ -60,11 +60,6 @@ export const treesApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getSharedTree(shareToken: string): Promise<SharedTree> {
|
||||
const response = await apiClient.get<SharedTree>(`/shared/${shareToken}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Tree validation
|
||||
async canPublish(id: string): Promise<TreeValidationResponse> {
|
||||
const response = await apiClient.post<TreeValidationResponse>(`/trees/${id}/can-publish`)
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
export { EmptyState } from '@/components/common/EmptyState'
|
||||
export { default } from '@/components/common/EmptyState'
|
||||
|
||||
@@ -12,7 +12,7 @@ export function PageHeader({ title, description, action, className }: PageHeader
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
|
||||
25
frontend/src/components/common/EmptyState.tsx
Normal file
25
frontend/src/components/common/EmptyState.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-black">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function RouteError() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-black p-8">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="mb-2 text-4xl font-bold text-foreground">Oops!</h1>
|
||||
<h2 className="mb-2 text-xl font-semibold text-red-400">{errorMessage}</h2>
|
||||
|
||||
26
frontend/src/components/common/Spinner.tsx
Normal file
26
frontend/src/components/common/Spinner.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-border border-t-primary',
|
||||
SIZES[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner
|
||||
@@ -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 && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
'rounded-full font-label',
|
||||
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
|
||||
'bg-accent/50 text-muted-foreground'
|
||||
)}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
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'
|
||||
import { TagCloud } from '@/components/sidebar/TagCloud'
|
||||
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
|
||||
import { NavItem } from './NavItem'
|
||||
import { categoriesApi, tagsApi, sessionsApi, treesApi } from '@/api'
|
||||
import { sessionsApi, treesApi } from '@/api'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
||||
|
||||
const [categories, setCategories] = useState<CategoryItem[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null)
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
||||
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
||||
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() {
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
{/* Categories */}
|
||||
<CategoryList
|
||||
categories={categories}
|
||||
activeId={activeCategoryId}
|
||||
onSelect={handleCategorySelect}
|
||||
/>
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
{/* Tags */}
|
||||
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
{/* Spacer — pushes footer to bottom */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Footer */}
|
||||
@@ -203,8 +128,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,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<string | null>(null)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(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({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDelete}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDeleteFolder}
|
||||
title="Delete Folder"
|
||||
message={pendingDelete?.message || ''}
|
||||
confirmLabel="Delete"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps)
|
||||
title={`${flow.tree_name} (right-click to unpin)`}
|
||||
>
|
||||
<span className="text-sm shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : '🔧'}
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
|
||||
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
|
||||
|
||||
@@ -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<TreeStructure | null>(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
||||
const panelRef = useRef<HTMLDivElement>(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<TreeStructure>) => {
|
||||
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 (
|
||||
<div ref={panelRef} className="flex h-[calc(100vh-105px)] w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
||||
<div ref={panelRef} className="flex h-full min-h-0 w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 shrink-0">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
|
||||
@@ -175,14 +186,14 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
||||
</div>
|
||||
|
||||
{/* Body — scrollable form area */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 scroll-pb-24">
|
||||
{draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
|
||||
{draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
|
||||
{draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 border-t border-border px-4 py-3 shrink-0">
|
||||
<div className="sticky bottom-0 flex items-center gap-2 border-t border-border bg-card px-4 py-3 shrink-0">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
@@ -236,6 +247,18 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDiscardConfirm}
|
||||
onClose={() => setShowDiscardConfirm(false)}
|
||||
onConfirm={() => {
|
||||
setShowDiscardConfirm(false)
|
||||
onClose()
|
||||
}}
|
||||
title="Discard Changes"
|
||||
message="You have unsaved changes. Discard them?"
|
||||
confirmLabel="Discard"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Node */}
|
||||
<NodePicker
|
||||
value={node.next_node_id || ''}
|
||||
onChange={(nodeId) => 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 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next step is linked — click it on the canvas to edit.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-yellow-400/70">
|
||||
Save to create a placeholder for the next step.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,21 +82,10 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
<div className="space-y-4">
|
||||
{/* Root node banner */}
|
||||
{isRootNode && (
|
||||
<div className="rounded-lg border-2 border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-blue-500/20 p-2">
|
||||
<Play className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-400">
|
||||
Starting Question
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This is the first question users will see when they start this troubleshooting tree.
|
||||
Each option below creates a different troubleshooting path.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2">
|
||||
<Play className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-blue-400">Starting Question</span>
|
||||
<InfoTip text="This is the first question users will see. Each option creates a different troubleshooting path." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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."} />
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">Options become answer placeholders you can fill in later.</p>
|
||||
{optionsError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionsError.message}</p>
|
||||
)}
|
||||
|
||||
@@ -143,10 +143,9 @@ Document what was done and the outcome.
|
||||
</div>
|
||||
|
||||
{/* Note about terminal node */}
|
||||
<div className="rounded-md bg-emerald-400/10 p-3 text-sm text-emerald-400">
|
||||
<strong>Note:</strong> Solution nodes are terminal - they end the troubleshooting flow.
|
||||
The session will be marked complete when the user reaches this node.
|
||||
</div>
|
||||
<p className="text-xs text-emerald-400/70">
|
||||
Solution nodes are terminal — the session completes when users reach this node.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,13 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Toaster
|
||||
position="top-right"
|
||||
expand={false}
|
||||
richColors
|
||||
closeButton
|
||||
visibleToasts={3}
|
||||
gap={8}
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
className: 'sonner-toast-custom',
|
||||
}}
|
||||
/>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -155,7 +156,7 @@ export function AccountSettingsPage() {
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">Account Settings</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Account Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage your account, subscription, and team
|
||||
@@ -585,7 +586,7 @@ function UsageStat({
|
||||
const isAtLimit = !isUnlimited && current >= max
|
||||
|
||||
return (
|
||||
<div className="glass-stat rounded-md p-3">
|
||||
<div className="rounded-md border border-border bg-card p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
|
||||
@@ -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
|
||||
@@ -135,7 +135,7 @@ export function FeedbackPage() {
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquareText className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">Send Feedback</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Send Feedback</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts.
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -62,14 +63,14 @@ export default function MyAnalyticsPage() {
|
||||
const outcomeBreakdown = summary.outcome_breakdown
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span title="My Analytics">
|
||||
<BarChart3 size={24} className="text-foreground" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-foreground">My Analytics</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground">My Analytics</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -84,7 +85,7 @@ export default function MyAnalyticsPage() {
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => 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) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [revokeTarget, setRevokeTarget] = useState<SessionShare | null>(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 (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -139,18 +141,20 @@ export default function MySharesPage() {
|
||||
|
||||
{/* Empty state */}
|
||||
{shares.length === 0 ? (
|
||||
<div className="bg-card border border-border rounded-xl p-12 text-center">
|
||||
<Link2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">No shared sessions</h2>
|
||||
<p className="text-muted-foreground text-sm mb-6">
|
||||
Share a session from the session detail page to create a link
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Go to Sessions
|
||||
</button>
|
||||
<div className="bg-card border border-border rounded-xl">
|
||||
<EmptyState
|
||||
icon={<Link2 className="h-12 w-12" />}
|
||||
title="No shared sessions"
|
||||
description="Share a session from the session detail page to create a link"
|
||||
action={
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Go to Sessions
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -223,7 +227,7 @@ export default function MySharesPage() {
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
onClick={() => setRevokeTarget(share)}
|
||||
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -235,6 +239,15 @@ export default function MySharesPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!revokeTarget}
|
||||
onClose={() => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -115,7 +116,7 @@ export function MyTreesPage() {
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">My Flows</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">My Flows</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Your forked and custom flows
|
||||
</p>
|
||||
@@ -177,7 +178,7 @@ export function MyTreesPage() {
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-accent px-4 py-12 text-center">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -83,13 +84,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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ export function ProceduralEditorPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -154,7 +155,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,8 @@ 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 { Spinner } from '@/components/common/Spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
@@ -35,6 +37,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 +120,7 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load flow')
|
||||
navigate('/my-trees')
|
||||
navigate('/trees')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -177,7 +180,7 @@ export function ProceduralNavigationPage() {
|
||||
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||
} catch {
|
||||
toast.error('Failed to resume session')
|
||||
navigate('/my-trees')
|
||||
navigate('/trees')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +291,7 @@ export function ProceduralNavigationPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -301,7 +304,7 @@ export function ProceduralNavigationPage() {
|
||||
fields={tree.intake_form || []}
|
||||
treeName={tree.name}
|
||||
onSubmit={handleIntakeSubmit}
|
||||
onCancel={() => navigate('/my-trees')}
|
||||
onCancel={() => navigate('/trees')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -327,7 +330,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 +357,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 +449,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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -243,8 +244,7 @@ export function SessionDetailPage() {
|
||||
rating: data.rating,
|
||||
review_text: data.review || undefined,
|
||||
was_helpful: data.helpful !== null ? data.helpful : undefined,
|
||||
session_id: session.id,
|
||||
is_verified_use: true
|
||||
session_id: session.id
|
||||
})
|
||||
)
|
||||
|
||||
@@ -289,7 +289,7 @@ export function SessionDetailPage() {
|
||||
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-primary" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -15,6 +17,7 @@ export function SessionHistoryPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all')
|
||||
@@ -109,8 +112,10 @@ export function SessionHistoryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsData = await sessionsApi.list(params)
|
||||
setSessions(sessionsData)
|
||||
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
|
||||
const truncated = sessionsData.length > 50
|
||||
setHasMore(truncated)
|
||||
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load sessions')
|
||||
console.error(err)
|
||||
@@ -188,27 +193,22 @@ export function SessionHistoryPage() {
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" />
|
||||
<Spinner />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No sessions found.{' '}
|
||||
{filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from ? (
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigate('/trees')}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Start a new session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No sessions found"
|
||||
description={filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from
|
||||
? "Try adjusting your filters"
|
||||
: "Complete a flow to see it here"}
|
||||
action={
|
||||
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
|
||||
<button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
|
||||
Clear all filters
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
@@ -298,6 +298,15 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasMore ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing the 50 most recent sessions
|
||||
</p>
|
||||
) : sessions.length > 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing all {sessions.length} sessions
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Spinner />
|
||||
<p className="text-sm text-muted-foreground">Loading shared session...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
25
frontend/src/pages/StepLibraryPage.tsx
Normal file
25
frontend/src/pages/StepLibraryPage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Bookmark } from 'lucide-react'
|
||||
|
||||
export default function StepLibraryPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span title="Step Library"><Bookmark className="h-8 w-8 text-muted-foreground" /></span>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Step Library</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">Reusable steps for your flows — coming soon.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Bookmark className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">Coming Soon</h2>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
The Step Library will let you create, share, and reuse common troubleshooting steps across all your flows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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, 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'
|
||||
@@ -44,31 +45,14 @@ 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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -84,14 +68,14 @@ export default function TeamAnalyticsPage() {
|
||||
const { summary, time_series, top_flows, top_engineers } = data
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span title="Team Analytics">
|
||||
<BarChart3 size={24} className="text-foreground" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-foreground">Team Analytics</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground">Team Analytics</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -104,7 +88,7 @@ export default function TeamAnalyticsPage() {
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => 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) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
|
||||
@@ -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'
|
||||
@@ -141,6 +142,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 +157,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 +165,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 {
|
||||
@@ -379,7 +383,7 @@ export function TreeEditorPage() {
|
||||
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" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ export function TreeLibraryPage() {
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showDrafts, setShowDrafts] = useState(false)
|
||||
|
||||
// Read type filter from URL query params (e.g. /trees?type=procedural)
|
||||
const urlType = searchParams.get('type')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
|
||||
@@ -75,7 +73,7 @@ export function TreeLibraryPage() {
|
||||
const lastSessionData = (() => {
|
||||
const raw = safeGetItem('last-session')
|
||||
if (!raw) return null
|
||||
try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string } }
|
||||
try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string; tree_type?: string } }
|
||||
catch { return null }
|
||||
})()
|
||||
|
||||
@@ -131,7 +129,7 @@ export function TreeLibraryPage() {
|
||||
// Load trees when filters change
|
||||
useEffect(() => {
|
||||
loadTrees()
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts, typeFilter])
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter])
|
||||
|
||||
// Load folders on mount and listen for changes
|
||||
useEffect(() => {
|
||||
@@ -150,7 +148,6 @@ export function TreeLibraryPage() {
|
||||
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
||||
folder_id: selectedFolderId || undefined,
|
||||
sort_by: treeLibrarySortBy,
|
||||
include_drafts: showDrafts || undefined,
|
||||
})
|
||||
setTrees(treesData)
|
||||
} catch (err) {
|
||||
@@ -326,33 +323,22 @@ export function TreeLibraryPage() {
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Type filter tabs — includes Drafts as a first-class filter */}
|
||||
{/* Type filter tabs */}
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
{(['all', 'troubleshooting', 'procedural', 'maintenance', 'drafts'] as const).map((t) => {
|
||||
const isActive = t === 'drafts' ? showDrafts && typeFilter === 'all' : !showDrafts && typeFilter === t
|
||||
return (
|
||||
{(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
if (t === 'drafts') {
|
||||
setShowDrafts(true)
|
||||
setTypeFilter('all')
|
||||
} else {
|
||||
setShowDrafts(false)
|
||||
setTypeFilter(t)
|
||||
}
|
||||
}}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
isActive
|
||||
typeFilter === t
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : t === 'maintenance' ? 'Maintenance' : 'Drafts'}
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right controls: sort + view toggle */}
|
||||
@@ -450,7 +436,7 @@ export function TreeLibraryPage() {
|
||||
{lastSessionData && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${lastSessionData.tree_id}/navigate`, {
|
||||
onClick={() => navigate(getSessionResumePath(lastSessionData.tree_id, lastSessionData.tree_type), {
|
||||
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
|
||||
})}
|
||||
className={cn(
|
||||
|
||||
@@ -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 (
|
||||
<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" />
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function TeamCategoriesPage() {
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Team Categories</h1>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground">Team Categories</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
|
||||
</div>
|
||||
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90')}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -31,6 +31,7 @@ const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
||||
const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
||||
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||
// Admin pages
|
||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||
@@ -235,6 +236,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'step-library',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<StepLibraryPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin',
|
||||
|
||||
@@ -48,7 +48,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Fetch user info
|
||||
await get().fetchUser()
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Login failed'
|
||||
const axiosErr = error as { response?: { data?: { detail?: unknown } } }
|
||||
const rawDetail = axiosErr.response?.data?.detail
|
||||
const message = (typeof rawDetail === 'string' ? rawDetail : null) || (error instanceof Error ? error.message : 'Login failed')
|
||||
set({ error: message, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
@@ -61,7 +63,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
// After registration, log the user in
|
||||
await get().login({ email: data.email, password: data.password })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Registration failed'
|
||||
const axiosErr = error as { response?: { data?: { detail?: unknown } } }
|
||||
const rawDetail = axiosErr.response?.data?.detail
|
||||
const message = (typeof rawDetail === 'string' ? rawDetail : null) || (error instanceof Error ? error.message : 'Registration failed')
|
||||
set({ error: message, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ export interface AccountInvite {
|
||||
email: string
|
||||
role: 'engineer' | 'viewer'
|
||||
code: string
|
||||
invited_by_id: string
|
||||
accepted_by_id: string | null
|
||||
expires_at: string
|
||||
expires_at: string | null
|
||||
used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ export interface RatingCreate {
|
||||
review_text?: string
|
||||
was_helpful?: boolean
|
||||
session_id?: string
|
||||
is_verified_use?: boolean
|
||||
}
|
||||
|
||||
export interface RatingUpdate {
|
||||
|
||||
@@ -215,7 +215,6 @@ export interface TreeFilters {
|
||||
is_active?: boolean
|
||||
author_id?: string
|
||||
is_public?: boolean
|
||||
include_drafts?: boolean
|
||||
sort_by?: 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
skip?: number
|
||||
limit?: number
|
||||
|
||||
Reference in New Issue
Block a user