fix: UX deep dive — 28 fixes across authoring, navigation, consistency, and cleanup #86
@@ -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