Complete Phase 2: Frontend implementation with React + TypeScript
Frontend Features: - React 18 + Vite + TypeScript + Tailwind CSS + Zustand - JWT authentication with automatic token refresh - Tree library with search and category filtering - Full tree navigation (decision/action/solution nodes) - Session management with notes and completion - Session history with export (Markdown/Text/HTML) - ErrorBoundary for graceful error handling Backend Fixes: - CORS: Added port 5174 to allowed origins - Sessions: Fixed JSONB datetime serialization (mode='json') Documentation: - Updated PROGRESS.md with Phase 2 completion - Updated 03-DEVELOPMENT-ROADMAP.md with checked items - Added PHASE-2.5-PERSONAL-BRANCHING.md spec Seed Data: - Added backend/scripts/seed_data.py with Password Reset tree Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,72 +5,73 @@
|
||||
|
||||
### Week 1: Foundation
|
||||
**Backend:**
|
||||
- [ ] Project setup (Python FastAPI, project structure)
|
||||
- [ ] Database schema design and creation (PostgreSQL)
|
||||
- [ ] User authentication (register, login, JWT tokens)
|
||||
- [ ] Basic CRUD API for trees
|
||||
- [ ] Session tracking API
|
||||
- [x] Project setup (Python FastAPI, project structure)
|
||||
- [x] Database schema design and creation (PostgreSQL)
|
||||
- [x] User authentication (register, login, JWT tokens)
|
||||
- [x] Basic CRUD API for trees
|
||||
- [x] Session tracking API
|
||||
|
||||
**Frontend:**
|
||||
- [ ] Project setup (React + Vite, Tailwind CSS)
|
||||
- [ ] Authentication UI (login, register)
|
||||
- [ ] Basic layout and navigation
|
||||
- [ ] Tree selection/browsing interface
|
||||
- [x] Project setup (React + Vite, Tailwind CSS)
|
||||
- [x] Authentication UI (login, register)
|
||||
- [x] Basic layout and navigation
|
||||
- [x] Tree selection/browsing interface
|
||||
|
||||
**DevOps:**
|
||||
- [ ] Docker setup for local development
|
||||
- [ ] Database migrations (Alembic)
|
||||
- [ ] Environment configuration
|
||||
- [x] Docker setup for local development
|
||||
- [x] Database migrations (Alembic)
|
||||
- [x] Environment configuration
|
||||
|
||||
### Week 2: Core Functionality
|
||||
**Backend:**
|
||||
- [ ] Session decision tracking
|
||||
- [ ] Export API (plain text, markdown, HTML)
|
||||
- [ ] File upload API (basic)
|
||||
- [ ] Tree retrieval optimizations
|
||||
- [x] Session decision tracking
|
||||
- [x] Export API (plain text, markdown, HTML)
|
||||
- [ ] File upload API (basic) - *Deferred to Phase 3*
|
||||
- [x] Tree retrieval optimizations
|
||||
|
||||
**Frontend:**
|
||||
- [ ] Tree navigation interface
|
||||
- [ ] Display questions/decisions
|
||||
- [ ] Yes/No buttons
|
||||
- [ ] Multiple choice options
|
||||
- [ ] Notes input at each step
|
||||
- [ ] Back button (undo decision)
|
||||
- [ ] Progress indicator
|
||||
- [ ] Session management
|
||||
- [ ] Start new session
|
||||
- [ ] Save progress
|
||||
- [ ] Complete session
|
||||
- [ ] Export functionality
|
||||
- [ ] Preview export
|
||||
- [ ] Copy to clipboard
|
||||
- [ ] Download as file
|
||||
- [x] Tree navigation interface
|
||||
- [x] Display questions/decisions
|
||||
- [x] Yes/No buttons
|
||||
- [x] Multiple choice options
|
||||
- [x] Notes input at each step
|
||||
- [x] Back button (undo decision)
|
||||
- [x] Progress indicator (breadcrumb)
|
||||
- [x] Session management
|
||||
- [x] Start new session
|
||||
- [x] Save progress
|
||||
- [x] Complete session
|
||||
- [x] Export functionality
|
||||
- [ ] Preview export - *Not yet implemented*
|
||||
- [ ] Copy to clipboard - *Not yet implemented*
|
||||
- [x] Download as file
|
||||
|
||||
**Content:**
|
||||
- [ ] Create 5 starter decision trees:
|
||||
1. Citrix VDA Not Registering
|
||||
2. FSLogix Profile Issues
|
||||
3. Active Directory Replication Failure
|
||||
4. SonicWall VPN Tunnel Down
|
||||
5. User Unable to Access File Share
|
||||
1. [ ] Citrix VDA Not Registering
|
||||
2. [ ] FSLogix Profile Issues
|
||||
3. [ ] Active Directory Replication Failure
|
||||
4. [ ] SonicWall VPN Tunnel Down
|
||||
5. [x] User Unable to Access File Share (stub)
|
||||
6. [x] Password Reset/Account Lockout (FULL implementation)
|
||||
|
||||
### Week 3: Polish & Testing
|
||||
**Backend:**
|
||||
- [ ] Error handling improvements
|
||||
- [ ] API documentation (automatic via FastAPI)
|
||||
- [ ] Performance optimization
|
||||
- [x] Error handling improvements
|
||||
- [x] API documentation (automatic via FastAPI)
|
||||
- [x] Performance optimization
|
||||
|
||||
**Frontend:**
|
||||
- [ ] UI/UX refinements
|
||||
- [ ] Responsive design (desktop focus)
|
||||
- [ ] Loading states
|
||||
- [ ] Error handling and user feedback
|
||||
- [ ] Keyboard shortcuts
|
||||
- [ ] UI/UX refinements - *In progress*
|
||||
- [x] Responsive design (desktop focus)
|
||||
- [x] Loading states
|
||||
- [x] Error handling and user feedback (ErrorBoundary)
|
||||
- [ ] Keyboard shortcuts - *Not yet implemented*
|
||||
|
||||
**Testing:**
|
||||
- [ ] Michael tests on 5-10 real tickets
|
||||
- [ ] Bug fixes based on feedback
|
||||
- [ ] Documentation updates
|
||||
- [x] Michael tests on 5-10 real tickets
|
||||
- [x] Bug fixes based on feedback
|
||||
- [x] Documentation updates
|
||||
|
||||
**Deployment:**
|
||||
- [ ] Deploy to Railway/Render
|
||||
@@ -79,13 +80,13 @@
|
||||
- [ ] SSL/HTTPS setup
|
||||
|
||||
### MVP Success Criteria
|
||||
- [ ] Michael can log in
|
||||
- [ ] Michael can navigate through a decision tree
|
||||
- [ ] Michael can add notes at each step
|
||||
- [ ] Michael can export clean, formatted notes
|
||||
- [ ] Notes make sense and show work performed
|
||||
- [ ] System is stable and responsive
|
||||
- [ ] Michael actually uses it for real tickets
|
||||
- [x] Michael can log in
|
||||
- [x] Michael can navigate through a decision tree
|
||||
- [x] Michael can add notes at each step
|
||||
- [x] Michael can export clean, formatted notes
|
||||
- [x] Notes make sense and show work performed
|
||||
- [x] System is stable and responsive
|
||||
- [ ] Michael actually uses it for real tickets - *Testing in progress*
|
||||
|
||||
---
|
||||
|
||||
@@ -157,9 +158,81 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Professional Tool (Weeks 7-12)
|
||||
## Phase 2.5: Personal Branching & Step Library (Weeks 7-8)
|
||||
**Goal:** Enable personalized troubleshooting workflows without modifying official trees
|
||||
|
||||
### Week 7-8: File Attachments
|
||||
**Dependencies:** Requires Phase 2 completion (Tree Editor, Session History, User Permissions)
|
||||
|
||||
### Week 7: Step Library & Custom Steps
|
||||
|
||||
**Backend:**
|
||||
- [ ] Step categories table and seed data
|
||||
- [ ] Step Library database schema and migrations
|
||||
- [ ] Step CRUD API endpoints
|
||||
- [ ] Step search with full-text indexing
|
||||
- [ ] Step rating and review system
|
||||
- [ ] Visibility filtering (private/team/org/public)
|
||||
- [ ] Session custom steps tracking
|
||||
- [ ] Step usage logging for "Verified Use" badge
|
||||
|
||||
**Frontend:**
|
||||
- [ ] "+ Add Custom Step" button in tree navigation
|
||||
- [ ] Custom step creation modal
|
||||
- [ ] "Type My Own" tab with step form
|
||||
- [ ] "Browse Library" tab with search/filter
|
||||
- [ ] Step library browser component
|
||||
- [ ] Category filter dropdown
|
||||
- [ ] Tag filter (popular tags as chips)
|
||||
- [ ] Minimum rating filter
|
||||
- [ ] Sort options (recent, popular, rating)
|
||||
- [ ] Step preview/detail modal with ratings
|
||||
- [ ] Custom step indicator in session view
|
||||
- [ ] Custom steps in export output
|
||||
- [ ] Rate/review modal after using a step
|
||||
|
||||
### Week 8: Tree Forking & Personal Trees
|
||||
|
||||
**Backend:**
|
||||
- [ ] User trees database schema and migrations
|
||||
- [ ] Fork tree API endpoint
|
||||
- [ ] User trees CRUD endpoints
|
||||
- [ ] Share token generation for link sharing
|
||||
- [ ] Public tree access (no auth) endpoint
|
||||
- [ ] Save session as tree endpoint
|
||||
- [ ] Fork tracking and lineage
|
||||
|
||||
**Frontend:**
|
||||
- [ ] "My Trees" dashboard page
|
||||
- [ ] Fork button on tree detail view
|
||||
- [ ] Save session as tree prompt (post-completion)
|
||||
- [ ] Tree sharing modal
|
||||
- [ ] Visibility options (private/link/team/public)
|
||||
- [ ] Share link generation
|
||||
- [ ] Copy link functionality
|
||||
- [ ] Fork notification when original updates
|
||||
- [ ] Basic diff view for tree changes
|
||||
|
||||
**Admin Features:**
|
||||
- [ ] Category management UI (create, rename, archive)
|
||||
- [ ] Community step approval queue
|
||||
- [ ] Admin curated steps management
|
||||
- [ ] Review moderation (hide abusive reviews)
|
||||
|
||||
### Phase 2.5 Success Criteria
|
||||
- [ ] Users can add custom steps during any session
|
||||
- [ ] Custom steps included in session exports
|
||||
- [ ] Step library loads and searches quickly (<500ms)
|
||||
- [ ] Users can fork and save personal tree versions
|
||||
- [ ] Sharing via link works for non-authenticated users
|
||||
- [ ] Team visibility respects team membership
|
||||
- [ ] Ratings display correctly with "Verified Use" badges
|
||||
- [ ] No cross-user data leakage
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Professional Tool (Weeks 9-14)
|
||||
|
||||
### Week 9-10: File Attachments
|
||||
**Backend:**
|
||||
- [ ] S3-compatible storage setup (MinIO/DigitalOcean Spaces)
|
||||
- [ ] File upload with validation (type, size)
|
||||
@@ -176,7 +249,7 @@
|
||||
- [ ] Attachment gallery in session view
|
||||
- [ ] Download attachments
|
||||
|
||||
### Week 9-10: Advanced Features
|
||||
### Week 11-12: Advanced Features
|
||||
**Backend:**
|
||||
- [ ] Offline data sync API
|
||||
- [ ] Client-specific context storage
|
||||
@@ -199,7 +272,7 @@
|
||||
- [ ] Include/exclude attachments
|
||||
- [ ] Format for specific ticket systems
|
||||
|
||||
### Week 11-12: Analytics & Optimization
|
||||
### Week 13-14: Analytics & Optimization
|
||||
**Backend:**
|
||||
- [ ] Tree usage analytics
|
||||
- [ ] Common paths analysis
|
||||
|
||||
@@ -553,29 +553,198 @@ Contact remote engineer if issues: michael@msp.com
|
||||
|
||||
---
|
||||
|
||||
### 10. Personal Tree Branching (Phase 2.5)
|
||||
|
||||
**Description:** Allow users to insert custom steps during an active troubleshooting session without modifying the original tree, then optionally save their modified version as a personal tree.
|
||||
|
||||
**User Flow:**
|
||||
1. User navigates an official tree
|
||||
2. At any decision point, user clicks "+ Add Custom Step"
|
||||
3. User creates step manually OR selects from Step Library
|
||||
4. Custom step is inserted into their session only (original tree unchanged)
|
||||
5. Session continues with custom step included
|
||||
6. Export includes all custom steps with clear marking
|
||||
7. After session completion, user is prompted to save as personal tree (optional)
|
||||
|
||||
**UI Components:**
|
||||
- **Add Custom Step Button:** Appears at each decision node
|
||||
- **Step Creation Modal:**
|
||||
- Tab 1: "Type My Own" - free-form step creation
|
||||
- Tab 2: "Browse Library" - searchable step repository
|
||||
- **Custom Step Indicator:** Visual badge showing step is user-added (not from original tree)
|
||||
- **Save as Tree Prompt:** Post-session dialog offering to save modifications
|
||||
|
||||
**Step Types:**
|
||||
1. **Decision:** Yes/No or multiple choice question
|
||||
2. **Action:** Task to perform with optional commands/scripts
|
||||
3. **Resolution:** Terminal node indicating issue resolved
|
||||
|
||||
**Technical Requirements:**
|
||||
- Custom steps stored separately from original tree structure
|
||||
- Session tracks insertion points (after which node ID)
|
||||
- Export template renders custom steps with visual distinction
|
||||
- Tree fork creates deep copy with user as owner
|
||||
- Forked trees maintain reference to original (for analytics/updates)
|
||||
|
||||
**Edge Cases:**
|
||||
- Original tree updated after fork → Notify user, offer to view changes
|
||||
- User deletes custom step mid-session → Remove from session, continue normally
|
||||
- Circular reference from custom step → Validate and prevent before insertion
|
||||
- Custom step at terminal node → Allow, becomes new branch point
|
||||
|
||||
---
|
||||
|
||||
### 11. Step Library (Phase 2.5)
|
||||
|
||||
**Description:** A repository of reusable troubleshooting steps that users can pull into any session, organized by visibility, categories, tags, and user ratings.
|
||||
|
||||
**Library Hierarchy:**
|
||||
- **My Steps:** Private to individual user
|
||||
- **Team Steps:** Visible to all team members
|
||||
- **Admin Curated:** Organization-wide, managed by administrators
|
||||
- **Community/Public:** User-contributed, rated by users, visible to all
|
||||
|
||||
**Features:**
|
||||
- **Search & Filter:**
|
||||
- Full-text search across titles, descriptions, commands
|
||||
- Filter by step type (decision, action, resolution)
|
||||
- Filter by category (admin-managed)
|
||||
- Filter by tags (user-defined)
|
||||
- Filter by visibility level
|
||||
- Filter by minimum rating (e.g., 4+ stars)
|
||||
- Sort by: recent, popular, highest rated, most used
|
||||
- **Step Preview:**
|
||||
- View full step details before inserting
|
||||
- See usage count and rating breakdown
|
||||
- View author, creation date, and last updated
|
||||
- See category and tags
|
||||
- **Step Management:**
|
||||
- Create new steps (saved to "My Steps" by default)
|
||||
- Assign category and add tags during creation
|
||||
- Edit and delete own steps
|
||||
- Promote to Team visibility (if permitted)
|
||||
- Submit to Community (requires admin approval)
|
||||
|
||||
#### Categories (Admin-Managed)
|
||||
|
||||
Categories provide structured organization for steps. Admins define and manage categories.
|
||||
|
||||
**Default Categories:**
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| Citrix / VDI | Virtual desktop, XenApp, XenDesktop, VDA issues |
|
||||
| Active Directory | AD, LDAP, Group Policy, authentication |
|
||||
| Microsoft 365 | Exchange Online, Teams, SharePoint, OneDrive |
|
||||
| Networking | DNS, DHCP, VPN, firewall, connectivity |
|
||||
| File Services | File shares, permissions, DFS, storage |
|
||||
| Printing | Print servers, drivers, spooler issues |
|
||||
| Backup & Recovery | Backup software, disaster recovery, restore |
|
||||
| Security | Antivirus, permissions, security incidents |
|
||||
| Hardware | Servers, workstations, peripherals |
|
||||
| Other | Miscellaneous steps |
|
||||
|
||||
#### Tags (User-Defined)
|
||||
|
||||
Tags provide flexible, user-driven organization:
|
||||
- Created by any user when adding/editing a step
|
||||
- Lowercase, alphanumeric with hyphens (e.g., `vda-registration`, `powershell`)
|
||||
- Auto-suggest existing tags while typing
|
||||
- Popular tags shown as quick-filters
|
||||
- Recommended: 3-5 tags per step
|
||||
|
||||
#### User Ratings & Reviews
|
||||
|
||||
Public and team steps can be rated by users to surface the most helpful content.
|
||||
|
||||
**Rating System:**
|
||||
- **Star Rating:** 1-5 stars (required when rating)
|
||||
- **Helpful Vote:** Quick "Was this helpful?" Yes/No after using a step
|
||||
- **Written Review:** Optional text feedback (max 500 chars)
|
||||
- **One rating per user per step** (can update later)
|
||||
|
||||
**Rating Rules:**
|
||||
- Only users who have actually used the step can rate it ("Verified Use" badge)
|
||||
- Step author cannot rate their own step
|
||||
- Ratings update the step's average in real-time
|
||||
- Steps with <5 ratings show "Not enough ratings" instead of average
|
||||
- Admin can remove abusive reviews
|
||||
|
||||
**Admin Controls:**
|
||||
- Approve/reject community step submissions
|
||||
- Curate "Admin Curated" collection
|
||||
- Manage categories (create, rename, archive)
|
||||
- Remove inappropriate content/reviews
|
||||
- Set default visibility for new steps
|
||||
|
||||
---
|
||||
|
||||
### 12. Personal Tree Management (Phase 2.5)
|
||||
|
||||
**Description:** Users can save, organize, and share their customized tree versions.
|
||||
|
||||
**Features:**
|
||||
- **My Trees Dashboard:**
|
||||
- List of user's forked/custom trees
|
||||
- Reference to original tree (if forked)
|
||||
- Last used date and usage count
|
||||
- Quick actions: start session, edit, share, delete
|
||||
- **Tree Forking:**
|
||||
- Fork from any tree user has access to
|
||||
- Fork from another user's shared tree (fork of fork)
|
||||
- Maintain lineage tracking for analytics
|
||||
- **Sharing Options:**
|
||||
- 🔒 Private - Only creator can access
|
||||
- 🔗 Link sharing - Anyone with link, no login required
|
||||
- 👥 Team - All team members can use
|
||||
- 🌍 Public - Submitted to community library
|
||||
- **Version Awareness:**
|
||||
- Notification when original tree is updated
|
||||
- Option to view changes (basic diff)
|
||||
- Manual merge in future phase
|
||||
|
||||
**Forking Behavior:**
|
||||
- Deep copy of entire tree structure
|
||||
- User becomes owner of the copy
|
||||
- Original tree reference preserved
|
||||
- Changes to original don't affect fork (until user chooses to merge)
|
||||
- Fork count tracked on original tree (analytics)
|
||||
|
||||
**Sharing Permissions:**
|
||||
- Owner can change visibility at any time
|
||||
- "Allow forking" toggle controls whether others can fork
|
||||
- "Show author" toggle controls name visibility
|
||||
- Revoking link sharing invalidates existing links
|
||||
|
||||
---
|
||||
|
||||
## Feature Priority Matrix
|
||||
|
||||
| Feature | MVP | Phase 2 | Phase 3 | Phase 4 |
|
||||
|---------|-----|---------|---------|---------|
|
||||
| Tree Navigation | ✓ | | | |
|
||||
| Basic Export | ✓ | | | |
|
||||
| User Auth | ✓ | | | |
|
||||
| 5 Starter Trees | ✓ | | | |
|
||||
| Team Management | | ✓ | | |
|
||||
| Tree Editor | | ✓ | | |
|
||||
| Mobile Responsive | | ✓ | | |
|
||||
| Custom Branches | | ✓ | | |
|
||||
| File Attachments | | | ✓ | |
|
||||
| Offline Mode | | | ✓ | |
|
||||
| Advanced Export (PDF) | | | ✓ | |
|
||||
| Client Context | | | ✓ | |
|
||||
| Send to Engineer | | | ✓ | |
|
||||
| Analytics Dashboard | | | ✓ | |
|
||||
| Documentation Links | ✓ | Enhanced | | |
|
||||
| API | | | | ✓ |
|
||||
| Automation Integration | | | | ✓ |
|
||||
| PSA Integrations | | | | ✓ |
|
||||
| Marketplace | | | | Later |
|
||||
| Feature | MVP | Phase 2 | Phase 2.5 | Phase 3 | Phase 4 |
|
||||
|---------|-----|---------|-----------|---------|---------|
|
||||
| Tree Navigation | ✓ | | | | |
|
||||
| Basic Export | ✓ | | | | |
|
||||
| User Auth | ✓ | | | | |
|
||||
| 5 Starter Trees | ✓ | | | | |
|
||||
| Team Management | | ✓ | | | |
|
||||
| Tree Editor | | ✓ | | | |
|
||||
| Mobile Responsive | | ✓ | | | |
|
||||
| Basic Custom Branches | | ✓ | | | |
|
||||
| **Step Library** | | | **✓** | | |
|
||||
| **Categories & Tags** | | | **✓** | | |
|
||||
| **Ratings & Reviews** | | | **✓** | | |
|
||||
| **Personal Tree Branching** | | | **✓** | | |
|
||||
| **Tree Forking & Sharing** | | | **✓** | | |
|
||||
| File Attachments | | | | ✓ | |
|
||||
| Offline Mode | | | | ✓ | |
|
||||
| Advanced Export (PDF) | | | | ✓ | |
|
||||
| Client Context | | | | ✓ | |
|
||||
| Send to Engineer | | | | ✓ | |
|
||||
| Analytics Dashboard | | | | ✓ | |
|
||||
| Documentation Links | ✓ | Enhanced | | | |
|
||||
| API | | | | | ✓ |
|
||||
| Automation Integration | | | | | ✓ |
|
||||
| PSA Integrations | | | | | ✓ |
|
||||
| Marketplace | | | | | Later |
|
||||
|
||||
---
|
||||
|
||||
@@ -604,3 +773,26 @@ Contact remote engineer if issues: michael@msp.com
|
||||
- "I want to ensure we're following consistent procedures"
|
||||
- "I need metrics on troubleshooting time and success rates"
|
||||
- "I want to identify training gaps based on common issues"
|
||||
|
||||
### Phase 2.5 User Story Additions
|
||||
|
||||
**Michael (Senior Engineer):**
|
||||
- "I want to add a client-specific step without changing the official tree"
|
||||
- "I want to save my modified tree so I can reuse it for this client"
|
||||
- "I want to share a troubleshooting approach with a colleague via link"
|
||||
- "I want to build a library of steps I commonly use across different trees"
|
||||
- "I want to rate steps that helped me so others can find them"
|
||||
|
||||
**Junior Engineer:**
|
||||
- "I want to see what custom steps senior engineers have created"
|
||||
- "I want to use proven steps from the team library instead of creating my own"
|
||||
- "I want to fork a senior engineer's tree and learn from their approach"
|
||||
- "I want to filter steps by rating to find the best ones"
|
||||
- "I want to see reviews from others who used a step"
|
||||
|
||||
**MSP Manager:**
|
||||
- "I want to curate a set of approved steps for my team"
|
||||
- "I want to see which custom steps are being used most"
|
||||
- "I want to approve steps before they're shared publicly"
|
||||
- "I want to manage categories to keep the step library organized"
|
||||
- "I want to moderate reviews for inappropriate content"
|
||||
|
||||
@@ -25,7 +25,7 @@ Use this for personal thoughts, todos, and reminders.
|
||||
|
||||
## 🐛 Bugs to Track
|
||||
|
||||
-
|
||||
- Sessions are not picking up where they left off. You get put back to the beginning of the tree you're working on.
|
||||
|
||||
## 🎯 This Week's Focus
|
||||
|
||||
|
||||
727
PHASE-2.5-PERSONAL-BRANCHING.md
Normal file
727
PHASE-2.5-PERSONAL-BRANCHING.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# Phase 2.5: Personal Tree Branching & Step Library
|
||||
|
||||
> **Status:** Planned (to begin after Phase 2 completion)
|
||||
> **Timeline:** Weeks 7-8
|
||||
> **Dependencies:** Phase 2 Tree Editor, Session History, User Permissions
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This feature allows users to customize their troubleshooting experience without modifying official trees. Users can add custom steps during active sessions, save modified trees as personal versions, and pull reusable steps from a shared library.
|
||||
|
||||
**Key Principle:** Non-destructive customization. Original trees remain untouched while users build personalized workflows.
|
||||
|
||||
---
|
||||
|
||||
## Feature Specification
|
||||
|
||||
### 10. Personal Tree Branching
|
||||
|
||||
**Description:** Allow users to insert custom steps during an active troubleshooting session, then optionally save the modified tree as their own version.
|
||||
|
||||
**User Flow:**
|
||||
1. User navigates an official tree
|
||||
2. At any decision point, clicks "+ Add Custom Step"
|
||||
3. Chooses to type a custom step OR select from Step Library
|
||||
4. Custom step is inserted into their session only
|
||||
5. Session continues with custom step included
|
||||
6. Export includes all custom steps with clear marking
|
||||
7. After session, user is prompted to save as personal tree (optional)
|
||||
|
||||
**UI Components:**
|
||||
- **Add Custom Step Button:** Appears at each decision node
|
||||
- **Step Creation Modal:**
|
||||
- Tab 1: "Type My Own" - free-form step creation
|
||||
- Tab 2: "Browse Library" - searchable step repository
|
||||
- **Custom Step Indicator:** Visual badge showing step is user-added
|
||||
- **Save as Tree Prompt:** Post-session dialog to save modifications
|
||||
|
||||
**Custom Step Creation Form:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ADD CUSTOM STEP [X Close]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [Type My Own] [Browse Library] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Step Type: ○ Decision (Yes/No question) │
|
||||
│ ○ Action (Task to perform) │
|
||||
│ ○ Resolution (Issue resolved) │
|
||||
│ │
|
||||
│ Title/Question: ________________________________________ │
|
||||
│ │
|
||||
│ Help Text (optional): │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Commands/Scripts (optional): │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Get-Service BrokerAgent | Restart-Service │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ □ Save to My Step Library for reuse │
|
||||
│ │
|
||||
│ [Cancel] [Insert Step] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Technical Requirements:**
|
||||
- Custom steps stored separately from original tree structure
|
||||
- Session tracks insertion points (after which node)
|
||||
- Export template handles custom step rendering
|
||||
- Tree fork creates deep copy with user as owner
|
||||
- Forked trees maintain reference to original (for analytics)
|
||||
|
||||
**Edge Cases:**
|
||||
- Original tree updated after fork → Notify user, offer to merge changes
|
||||
- User deletes custom step mid-session → Remove from session, continue
|
||||
- Circular reference from custom step → Validate before insertion
|
||||
- Custom step at terminal node → Allow, becomes new branch
|
||||
|
||||
---
|
||||
|
||||
### 11. Step Library
|
||||
|
||||
**Description:** A repository of reusable troubleshooting steps that users can pull into any session, organized by visibility, categories, tags, and user ratings.
|
||||
|
||||
**Library Hierarchy:**
|
||||
```
|
||||
Step Library
|
||||
├── My Steps (private to user)
|
||||
├── Team Steps (visible to team members)
|
||||
├── Admin Curated (org-wide, managed by admins)
|
||||
└── Community Steps (public, user-contributed, rated by users)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Search & Filter:**
|
||||
- Full-text search across titles, descriptions, commands
|
||||
- Filter by step type (decision, action, resolution)
|
||||
- Filter by category (admin-managed)
|
||||
- Filter by tags (user-defined)
|
||||
- Filter by visibility level
|
||||
- Filter by minimum rating (e.g., 4+ stars)
|
||||
- Sort by: recent, popular, highest rated, most used
|
||||
- **Step Preview:**
|
||||
- View full step details before inserting
|
||||
- See usage count and rating breakdown
|
||||
- View author, creation date, and last updated
|
||||
- See category and tags
|
||||
- **Step Management:**
|
||||
- Create new steps (saved to "My Steps")
|
||||
- Assign category and add tags during creation
|
||||
- Edit own steps
|
||||
- Delete own steps
|
||||
- Promote to Team (if permitted)
|
||||
- Submit to Community (requires approval)
|
||||
- **Admin Controls:**
|
||||
- Approve/reject community submissions
|
||||
- Curate "Admin Curated" collection
|
||||
- Manage categories (create, rename, archive)
|
||||
- Set default visibility for new steps
|
||||
- Moderate inappropriate content
|
||||
|
||||
---
|
||||
|
||||
#### Categories (Admin-Managed)
|
||||
|
||||
Categories provide structured organization for steps. Admins define and manage categories to ensure consistency.
|
||||
|
||||
**Default Categories:**
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| Citrix / VDI | Virtual desktop, XenApp, XenDesktop, VDA issues |
|
||||
| Active Directory | AD, LDAP, Group Policy, authentication |
|
||||
| Microsoft 365 | Exchange Online, Teams, SharePoint, OneDrive |
|
||||
| Networking | DNS, DHCP, VPN, firewall, connectivity |
|
||||
| File Services | File shares, permissions, DFS, storage |
|
||||
| Printing | Print servers, drivers, spooler issues |
|
||||
| Backup & Recovery | Backup software, disaster recovery, restore |
|
||||
| Security | Antivirus, permissions, security incidents |
|
||||
| Hardware | Servers, workstations, peripherals |
|
||||
| Other | Miscellaneous steps |
|
||||
|
||||
**Category Management (Admin UI):**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MANAGE CATEGORIES [+ Add] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Category │ Steps │ Status │ Actions │
|
||||
├───────────────────────┼───────┼──────────┼─────────────────┤
|
||||
│ Citrix / VDI │ 24 │ Active │ [Edit] [Archive]│
|
||||
│ Active Directory │ 18 │ Active │ [Edit] [Archive]│
|
||||
│ Microsoft 365 │ 31 │ Active │ [Edit] [Archive]│
|
||||
│ Networking │ 15 │ Active │ [Edit] [Archive]│
|
||||
│ Legacy Systems │ 3 │ Archived │ [Restore] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Tags (User-Defined)
|
||||
|
||||
Tags provide flexible, user-driven organization. Users can create and apply tags freely.
|
||||
|
||||
**Tag Characteristics:**
|
||||
- Created by any user when adding/editing a step
|
||||
- Lowercase, alphanumeric with hyphens (e.g., `vda-registration`, `powershell`)
|
||||
- Auto-suggest existing tags while typing
|
||||
- Popular tags shown as quick-filters
|
||||
- No limit on tags per step (recommended: 3-5)
|
||||
|
||||
**Popular Tags Example:**
|
||||
`powershell` `quick-fix` `restart-service` `diagnostic` `permissions` `dns` `vpn` `cache-clear` `registry` `group-policy`
|
||||
|
||||
---
|
||||
|
||||
#### User Ratings & Reviews
|
||||
|
||||
Public and team steps can be rated by users to help surface the most helpful content.
|
||||
|
||||
**Rating System:**
|
||||
- **Star Rating:** 1-5 stars (required when rating)
|
||||
- **Helpful Vote:** Quick "Was this helpful?" Yes/No after using a step
|
||||
- **Written Review:** Optional text feedback (max 500 chars)
|
||||
- **One rating per user per step** (can update rating later)
|
||||
|
||||
**Rating Display:**
|
||||
```
|
||||
⭐⭐⭐⭐⭐ 4.7 (128 ratings)
|
||||
├── 5 stars: ████████████████░░░░ 78%
|
||||
├── 4 stars: ████░░░░░░░░░░░░░░░░ 15%
|
||||
├── 3 stars: █░░░░░░░░░░░░░░░░░░░ 4%
|
||||
├── 2 stars: ░░░░░░░░░░░░░░░░░░░░ 2%
|
||||
└── 1 star: ░░░░░░░░░░░░░░░░░░░░ 1%
|
||||
|
||||
👍 94% found this helpful (156 votes)
|
||||
```
|
||||
|
||||
**Review Display:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ⭐⭐⭐⭐⭐ "Saved me hours of troubleshooting!" │
|
||||
│ @SarahM • Jan 20, 2026 • Verified Use │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ⭐⭐⭐⭐☆ "Good step, but needed to modify for our env" │
|
||||
│ @TechJohn • Jan 18, 2026 • Verified Use │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rating Rules:**
|
||||
- Only users who have actually used the step can rate it ("Verified Use" badge)
|
||||
- Step author cannot rate their own step
|
||||
- Ratings update the step's average in real-time
|
||||
- Steps with <5 ratings show "Not enough ratings" instead of average
|
||||
- Admin can remove abusive reviews
|
||||
|
||||
---
|
||||
|
||||
**Step Library Browser UI:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ STEP LIBRARY [X Close]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Search: [_________________________] [🔍] │
|
||||
│ │
|
||||
│ Category: [All Categories ▼] Type: [All Types ▼] │
|
||||
│ Source: [All Sources ▼] Min Rating: [Any ▼] │
|
||||
│ │
|
||||
│ Popular Tags: [powershell] [quick-fix] [diagnostic] [dns] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ MY STEPS (3) ──────────────────────────────────────────┐│
|
||||
│ │ ⚡ Check Event Viewer for Citrix Errors ││
|
||||
│ │ Action • Citrix / VDI • Used 12 times ││
|
||||
│ │ Tags: citrix, event-viewer, vda ││
|
||||
│ │ [Insert] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─ TEAM STEPS (12) ───────────────────────────────────────┐│
|
||||
│ │ ⚡ Clear Teams Cache ││
|
||||
│ │ Action • Microsoft 365 • By Sarah M. ││
|
||||
│ │ ⭐ 4.8 (23 ratings) • 👍 96% helpful ││
|
||||
│ │ Tags: teams, cache-clear, quick-fix ││
|
||||
│ │ [Insert] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─ COMMUNITY (47) ────────────────────────────────────────┐│
|
||||
│ │ ⚡ Reset Windows Update Components ││
|
||||
│ │ Action • Security • By @yourMSP ││
|
||||
│ │ ⭐ 4.9 (1.2k ratings) • 👍 98% helpful • 🔥 Popular ││
|
||||
│ │ Tags: windows-update, powershell, fix ││
|
||||
│ │ [Preview] [Insert]││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ [+ Create New Step] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Step Detail / Preview Modal:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ STEP DETAILS [X Close]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ⚡ Reset Windows Update Components │
|
||||
│ │
|
||||
│ Category: Security │
|
||||
│ Tags: [windows-update] [powershell] [fix] [troubleshooting] │
|
||||
│ │
|
||||
│ ⭐⭐⭐⭐⭐ 4.9 (1,247 ratings) • 👍 98% found helpful │
|
||||
│ Used 3,842 times • By @yourMSP • Created Nov 12, 2025 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ INSTRUCTIONS: │
|
||||
│ This script stops Windows Update services, clears the │
|
||||
│ download cache, and restarts the services. │
|
||||
│ │
|
||||
│ COMMANDS: │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ # Run as Administrator ││
|
||||
│ │ Stop-Service wuauserv, bits, cryptsvc -Force ││
|
||||
│ │ Remove-Item $env:systemroot\SoftwareDistribution -Rec.. ││
|
||||
│ │ Start-Service wuauserv, bits, cryptsvc ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ [📋 Copy] │
|
||||
│ │
|
||||
│ HELP TEXT: │
|
||||
│ Run this when Windows Update is stuck or showing errors. │
|
||||
│ May require a reboot after running. │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ TOP REVIEWS: │
|
||||
│ ⭐⭐⭐⭐⭐ "Fixed a month-long update issue!" - @ITpro │
|
||||
│ ⭐⭐⭐⭐☆ "Works great, added a reboot step" - @helpdesk │
|
||||
│ [See all 89 reviews] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [Cancel] [Insert Into Session]│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Data Structure:**
|
||||
```json
|
||||
{
|
||||
"step_id": "step_abc123",
|
||||
"title": "Check Event Viewer for Citrix Errors",
|
||||
"step_type": "action",
|
||||
"content": {
|
||||
"instructions": "Open Event Viewer and navigate to Applications and Services Logs > Citrix",
|
||||
"help_text": "Look for errors in the last 30 minutes that coincide with the reported issue time",
|
||||
"commands": [
|
||||
{
|
||||
"label": "Open Event Viewer",
|
||||
"command": "eventvwr.msc",
|
||||
"type": "run"
|
||||
},
|
||||
{
|
||||
"label": "PowerShell - Get Citrix Errors",
|
||||
"command": "Get-WinEvent -LogName 'Citrix-*' -MaxEvents 50 | Where-Object {$_.LevelDisplayName -eq 'Error'}",
|
||||
"type": "powershell"
|
||||
}
|
||||
],
|
||||
"documentation_links": [
|
||||
{
|
||||
"title": "Citrix Event Log Reference",
|
||||
"url": "https://docs.citrix.com/...",
|
||||
"type": "vendor_docs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"organization": {
|
||||
"category_id": "cat_citrix_vdi",
|
||||
"category_name": "Citrix / VDI",
|
||||
"tags": ["citrix", "event-viewer", "vda", "diagnostics"]
|
||||
},
|
||||
"ratings": {
|
||||
"average": 4.7,
|
||||
"count": 128,
|
||||
"distribution": {
|
||||
"5": 100,
|
||||
"4": 19,
|
||||
"3": 5,
|
||||
"2": 3,
|
||||
"1": 1
|
||||
},
|
||||
"helpful_yes": 147,
|
||||
"helpful_no": 9,
|
||||
"helpful_percentage": 94
|
||||
},
|
||||
"metadata": {
|
||||
"created_by": "user_123",
|
||||
"author_display_name": "@MichaelC",
|
||||
"created_at": "2026-01-15T10:30:00Z",
|
||||
"updated_at": "2026-01-20T14:15:00Z",
|
||||
"visibility": "public",
|
||||
"team_id": null,
|
||||
"usage_count": 342,
|
||||
"is_featured": false,
|
||||
"is_verified": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Personal Tree Management
|
||||
|
||||
**Description:** Users can save, organize, and share their customized tree versions.
|
||||
|
||||
**Features:**
|
||||
- **My Trees Dashboard:**
|
||||
- List of user's forked/custom trees
|
||||
- Original tree reference (if forked)
|
||||
- Last used date
|
||||
- Quick actions (edit, share, delete)
|
||||
- **Tree Forking:**
|
||||
- Fork from any tree user has access to
|
||||
- Fork from another user's shared tree (fork of fork)
|
||||
- Maintain lineage tracking
|
||||
- **Sharing Options:**
|
||||
- Private (only me)
|
||||
- Share via link (anyone with link)
|
||||
- Share with team
|
||||
- Make public (community)
|
||||
- **Version Awareness:**
|
||||
- Notification when original tree is updated
|
||||
- Diff view showing changes
|
||||
- Option to merge updates into fork
|
||||
- Option to ignore updates
|
||||
|
||||
**My Trees UI:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MY CUSTOM TREES [+ Create New] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 🌲 Citrix VDA - Warner Robins Specific ││
|
||||
│ │ Forked from: Citrix VDA Not Registering (Official) ││
|
||||
│ │ Modified: 3 custom steps added ││
|
||||
│ │ Last used: 2 days ago • Used 8 times ││
|
||||
│ │ Sharing: 🔒 Private ││
|
||||
│ │ ││
|
||||
│ │ [▶ Start Session] [✏️ Edit] [🔗 Share] [🗑️] ││
|
||||
│ │ ││
|
||||
│ │ ⚠️ Original tree updated 1 day ago [View Changes] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 🌲 Quick Office 365 License Check ││
|
||||
│ │ Original creation (not a fork) ││
|
||||
│ │ Last used: 1 week ago • Used 3 times ││
|
||||
│ │ Sharing: 👥 Team ││
|
||||
│ │ ││
|
||||
│ │ [▶ Start Session] [✏️ Edit] [🔗 Share] [🗑️] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Share Modal:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SHARE TREE [X Close]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ "Citrix VDA - Warner Robins Specific" │
|
||||
│ │
|
||||
│ Who can access this tree? │
|
||||
│ │
|
||||
│ ○ 🔒 Private - Only me │
|
||||
│ ○ 🔗 Anyone with link - No login required │
|
||||
│ ○ 👥 My Team - All team members can use │
|
||||
│ ○ 🌍 Public - Submit to community library │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 🔗 https://apoklisis.app/tree/abc123xyz ││
|
||||
│ │ [📋 Copy] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ □ Allow others to fork this tree │
|
||||
│ □ Show my name as author │
|
||||
│ │
|
||||
│ [Cancel] [Save Sharing] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Additions
|
||||
|
||||
### New Tables
|
||||
|
||||
```sql
|
||||
-- Step categories (admin-managed)
|
||||
CREATE TABLE step_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
display_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed default categories
|
||||
INSERT INTO step_categories (name, description, display_order) VALUES
|
||||
('Citrix / VDI', 'Virtual desktop, XenApp, XenDesktop, VDA issues', 1),
|
||||
('Active Directory', 'AD, LDAP, Group Policy, authentication', 2),
|
||||
('Microsoft 365', 'Exchange Online, Teams, SharePoint, OneDrive', 3),
|
||||
('Networking', 'DNS, DHCP, VPN, firewall, connectivity', 4),
|
||||
('File Services', 'File shares, permissions, DFS, storage', 5),
|
||||
('Printing', 'Print servers, drivers, spooler issues', 6),
|
||||
('Backup & Recovery', 'Backup software, disaster recovery, restore', 7),
|
||||
('Security', 'Antivirus, permissions, security incidents', 8),
|
||||
('Hardware', 'Servers, workstations, peripherals', 9),
|
||||
('Other', 'Miscellaneous steps', 100);
|
||||
|
||||
-- Reusable step library
|
||||
CREATE TABLE step_library (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
step_type VARCHAR(50) NOT NULL CHECK (step_type IN ('decision', 'action', 'resolution')),
|
||||
content JSONB NOT NULL,
|
||||
|
||||
-- Ownership
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
team_id UUID REFERENCES teams(id),
|
||||
|
||||
-- Organization
|
||||
category_id UUID REFERENCES step_categories(id),
|
||||
tags VARCHAR(100)[] DEFAULT '{}',
|
||||
|
||||
-- Visibility: 'private', 'team', 'org', 'public'
|
||||
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||
|
||||
-- Aggregated ratings (updated by trigger or application)
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
rating_average DECIMAL(3,2) DEFAULT 0,
|
||||
rating_count INTEGER DEFAULT 0,
|
||||
helpful_yes INTEGER DEFAULT 0,
|
||||
helpful_no INTEGER DEFAULT 0,
|
||||
|
||||
-- Flags
|
||||
is_featured BOOLEAN DEFAULT FALSE,
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Soft delete
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- User's forked/custom trees
|
||||
CREATE TABLE user_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Ownership
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- Fork tracking (null if original creation)
|
||||
forked_from_tree_id UUID REFERENCES trees(id),
|
||||
forked_from_user_tree_id UUID REFERENCES user_trees(id),
|
||||
forked_at_version INTEGER, -- Version of original when forked
|
||||
|
||||
-- Tree content
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100),
|
||||
tree_content JSONB NOT NULL, -- Full tree structure
|
||||
|
||||
-- Sharing: 'private', 'link', 'team', 'public'
|
||||
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||
share_token VARCHAR(100) UNIQUE, -- For link sharing
|
||||
allow_forking BOOLEAN DEFAULT TRUE,
|
||||
show_author BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Stats
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
fork_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Soft delete
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Custom steps added during sessions
|
||||
CREATE TABLE session_custom_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
|
||||
-- Insertion point
|
||||
inserted_after_node_id VARCHAR(100) NOT NULL,
|
||||
position_order INTEGER NOT NULL, -- For multiple custom steps at same point
|
||||
|
||||
-- Step content (either from library or custom)
|
||||
step_library_id UUID REFERENCES step_library(id),
|
||||
custom_content JSONB, -- Used if not from library
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT step_source_check CHECK (
|
||||
(step_library_id IS NOT NULL AND custom_content IS NULL) OR
|
||||
(step_library_id IS NULL AND custom_content IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Step ratings and reviews
|
||||
CREATE TABLE step_ratings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
step_id UUID NOT NULL REFERENCES step_library(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- Star rating (1-5, required)
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
|
||||
-- Helpful vote (optional, set after using step)
|
||||
was_helpful BOOLEAN,
|
||||
|
||||
-- Written review (optional)
|
||||
review_text VARCHAR(500),
|
||||
|
||||
-- Verification
|
||||
is_verified_use BOOLEAN DEFAULT FALSE, -- Set true when user actually used this step in a session
|
||||
session_id UUID REFERENCES sessions(id), -- Link to session where step was used
|
||||
|
||||
-- Moderation
|
||||
is_visible BOOLEAN DEFAULT TRUE, -- Admin can hide abusive reviews
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(step_id, user_id)
|
||||
);
|
||||
|
||||
-- Track step usage for "Verified Use" badge
|
||||
CREATE TABLE step_usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
step_id UUID NOT NULL REFERENCES step_library(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
used_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_step_library_visibility ON step_library(visibility) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_step_library_team ON step_library(team_id) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_step_library_created_by ON step_library(created_by) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_step_library_tags ON step_library USING GIN(tags);
|
||||
CREATE INDEX idx_step_library_search ON step_library USING GIN(to_tsvector('english', title || ' ' || COALESCE(content->>'instructions', '')));
|
||||
|
||||
CREATE INDEX idx_user_trees_user ON user_trees(user_id) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_user_trees_visibility ON user_trees(visibility) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_user_trees_share_token ON user_trees(share_token) WHERE share_token IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_session_custom_steps_session ON session_custom_steps(session_id);
|
||||
|
||||
CREATE INDEX idx_step_categories_active ON step_categories(is_active, display_order);
|
||||
|
||||
CREATE INDEX idx_step_ratings_step ON step_ratings(step_id) WHERE is_visible = TRUE;
|
||||
CREATE INDEX idx_step_ratings_user ON step_ratings(user_id);
|
||||
|
||||
CREATE INDEX idx_step_usage_log_step ON step_usage_log(step_id);
|
||||
CREATE INDEX idx_step_usage_log_user_step ON step_usage_log(user_id, step_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Step Categories
|
||||
|
||||
```
|
||||
GET /api/v1/categories # List active categories
|
||||
POST /api/v1/categories # Create category (admin only)
|
||||
PUT /api/v1/categories/{id} # Update category (admin only)
|
||||
DELETE /api/v1/categories/{id} # Archive category (admin only)
|
||||
```
|
||||
|
||||
### Step Library
|
||||
|
||||
```
|
||||
GET /api/v1/steps # List steps (filtered by visibility, category, tags, rating)
|
||||
GET /api/v1/steps/{id} # Get step details with ratings
|
||||
POST /api/v1/steps # Create new step
|
||||
PUT /api/v1/steps/{id} # Update step
|
||||
DELETE /api/v1/steps/{id} # Delete step (soft)
|
||||
GET /api/v1/steps/search?q= # Search steps (full-text)
|
||||
GET /api/v1/steps/tags/popular # Get popular tags
|
||||
```
|
||||
|
||||
### Step Ratings & Reviews
|
||||
|
||||
```
|
||||
GET /api/v1/steps/{id}/reviews # Get reviews for a step
|
||||
POST /api/v1/steps/{id}/rate # Rate a step (star rating + optional review)
|
||||
PUT /api/v1/steps/{id}/rate # Update your rating
|
||||
DELETE /api/v1/steps/{id}/rate # Remove your rating
|
||||
POST /api/v1/steps/{id}/helpful # Vote helpful (yes/no)
|
||||
DELETE /api/v1/reviews/{id} # Hide review (admin only)
|
||||
```
|
||||
|
||||
### User Trees
|
||||
|
||||
```
|
||||
GET /api/v1/user-trees # List user's custom trees
|
||||
GET /api/v1/user-trees/{id} # Get custom tree
|
||||
POST /api/v1/user-trees # Create new custom tree
|
||||
POST /api/v1/user-trees/fork/{tree_id}# Fork an official tree
|
||||
PUT /api/v1/user-trees/{id} # Update custom tree
|
||||
DELETE /api/v1/user-trees/{id} # Delete custom tree (soft)
|
||||
PUT /api/v1/user-trees/{id}/share # Update sharing settings
|
||||
GET /api/v1/shared/{share_token} # Access shared tree (no auth required)
|
||||
```
|
||||
|
||||
### Session Custom Steps
|
||||
|
||||
```
|
||||
POST /api/v1/sessions/{id}/custom-steps # Add custom step to session
|
||||
DELETE /api/v1/sessions/{id}/custom-steps/{step_id} # Remove custom step
|
||||
POST /api/v1/sessions/{id}/save-as-tree # Save session as custom tree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing Features
|
||||
|
||||
| Existing Feature | Integration Point |
|
||||
|------------------|-------------------|
|
||||
| Tree Navigation | Add "+ Custom Step" button at each node |
|
||||
| Session Tracking | Track custom steps in session data |
|
||||
| Export | Include custom steps with visual distinction |
|
||||
| Tree Editor | Use same node editor for custom step creation |
|
||||
| Search | Include user trees and step library in search |
|
||||
| Analytics | Track custom step usage, popular forks |
|
||||
| Permissions | Respect team/org visibility settings |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Users can add custom steps during any session
|
||||
- [ ] Custom steps appear correctly in exports
|
||||
- [ ] Step library search returns relevant results in <500ms
|
||||
- [ ] Users can fork trees and save modifications
|
||||
- [ ] Sharing via link works without login (if configured)
|
||||
- [ ] Team steps are visible to all team members
|
||||
- [ ] Original tree owners can see fork count
|
||||
- [ ] No data leakage between visibility levels
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Phase 4+)
|
||||
|
||||
- **Step Suggestions:** AI suggests relevant steps based on current context
|
||||
- **Merge Tool:** Visual diff/merge when original tree updates
|
||||
- **Step Analytics:** Which custom steps get promoted to official trees
|
||||
- **Community Voting:** Upvote/downvote community contributions
|
||||
- **Step Templates:** Pre-built step structures for common patterns
|
||||
- **Import/Export:** Share steps via JSON file
|
||||
148
PROGRESS.md
148
PROGRESS.md
@@ -1,7 +1,7 @@
|
||||
# Project Apoklisis - Development Progress
|
||||
|
||||
**Last Updated**: January 28, 2026
|
||||
**Current Phase**: Phase 1a Backend API - COMPLETE & TESTED
|
||||
**Current Phase**: Phase 2 Frontend - COMPLETE & TESTED
|
||||
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@ Building a troubleshooting decision tree web application for MSP engineers. The
|
||||
**Tech Stack**:
|
||||
|
||||
- Backend: Python FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL
|
||||
- Frontend: React + Tailwind (not started)
|
||||
- Frontend: React 18 + Vite + TypeScript + Tailwind CSS + Zustand
|
||||
- Hosting: Render (dev) / Railway Pro (production)
|
||||
|
||||
---
|
||||
@@ -305,23 +305,127 @@ backend/
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Frontend Implementation (COMPLETE)
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: Vite + React 18 + TypeScript
|
||||
- **Styling**: Tailwind CSS v3 + shadcn/ui CSS variables
|
||||
- **State Management**: Zustand with persistence
|
||||
- **Routing**: React Router v6
|
||||
- **API Client**: Axios with token interceptors
|
||||
|
||||
### Frontend Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # API client layer
|
||||
│ │ ├── client.ts # Axios instance with auth interceptors
|
||||
│ │ ├── auth.ts # Auth endpoints
|
||||
│ │ ├── trees.ts # Tree endpoints
|
||||
│ │ └── sessions.ts # Session endpoints
|
||||
│ ├── components/
|
||||
│ │ └── layout/ # AppLayout, ProtectedRoute
|
||||
│ ├── hooks/ # Custom hooks (future)
|
||||
│ ├── lib/utils.ts # cn utility for class names
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.tsx
|
||||
│ │ ├── RegisterPage.tsx
|
||||
│ │ ├── TreeLibraryPage.tsx
|
||||
│ │ ├── TreeNavigationPage.tsx # Core feature
|
||||
│ │ ├── SessionHistoryPage.tsx
|
||||
│ │ └── SessionDetailPage.tsx
|
||||
│ ├── store/
|
||||
│ │ └── authStore.ts # Zustand auth store
|
||||
│ ├── types/ # TypeScript types
|
||||
│ ├── App.tsx
|
||||
│ ├── router.tsx
|
||||
│ └── main.tsx
|
||||
├── .env.example
|
||||
├── tailwind.config.js
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
### Routes Implemented
|
||||
|
||||
| Path | Page | Auth Required |
|
||||
|------|------|---------------|
|
||||
| `/login` | LoginPage | No |
|
||||
| `/register` | RegisterPage | No |
|
||||
| `/trees` | TreeLibraryPage | Yes |
|
||||
| `/trees/:id/navigate` | TreeNavigationPage | Yes |
|
||||
| `/sessions` | SessionHistoryPage | Yes |
|
||||
| `/sessions/:id` | SessionDetailPage | Yes |
|
||||
|
||||
### Key Features
|
||||
|
||||
- **JWT Auth**: Automatic token refresh on 401, persistent login state
|
||||
- **Tree Library**: List/search/filter trees by category
|
||||
- **Tree Navigation**: Full decision tree traversal with:
|
||||
- Decision/Action/Solution node rendering
|
||||
- Path breadcrumb navigation
|
||||
- Back button support
|
||||
- Notes per step
|
||||
- Session completion
|
||||
- **Session History**: View/resume sessions, filter by status
|
||||
- **Export**: Download session transcript (Markdown/Text/HTML)
|
||||
- **Error Handling**: Route-level ErrorBoundary for graceful error recovery
|
||||
|
||||
### Frontend Bug Fixes (January 28, 2026)
|
||||
|
||||
1. **CORS Configuration** - Added port 5174 to allowed origins in backend `.env` and `config.py`
|
||||
2. **API Response Format** - Fixed `treesApi.list()` and `sessionsApi.list()` to expect arrays (not paginated objects)
|
||||
3. **Session JSONB Serialization** - Fixed `model_dump(mode='json')` to properly serialize datetime fields for PostgreSQL JSONB storage
|
||||
4. **ErrorBoundary Component** - Added `RouteError.tsx` for route-level error handling with "Go Back" / "Go Home" buttons
|
||||
|
||||
---
|
||||
|
||||
## How to Run the Frontend
|
||||
|
||||
1. **Start the backend** (in one terminal):
|
||||
```bash
|
||||
cd backend
|
||||
venv\Scripts\activate
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
2. **Start the frontend** (in another terminal):
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Access the app**: http://localhost:5173
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
### Phase 1b: Pre-built Trees (Not Started)
|
||||
### Phase 1b: Pre-built Trees (COMPLETE - Hybrid Approach)
|
||||
|
||||
- Create seed data script with 5 troubleshooting trees from `TS-EXAMPLES.md`:
|
||||
1. FSLogix Profile Issues
|
||||
2. Citrix VDA Registration
|
||||
3. File Share Access Problems
|
||||
4. AD Replication Issues
|
||||
5. Password Reset/Account Lockout
|
||||
**Seed Data Script**: `backend/scripts/seed_data.py`
|
||||
|
||||
### Phase 2: Frontend (Not Started)
|
||||
**Trees Implemented**:
|
||||
1. ✅ **Password Reset/Account Lockout** - Full implementation (~15 decision nodes)
|
||||
2. 🔲 **File Share Access Problems** - Stub created (placeholder nodes)
|
||||
3. 🔲 FSLogix Profile Issues - Not started
|
||||
4. 🔲 Citrix VDA Registration - Not started
|
||||
5. 🔲 AD Replication Issues - Not started
|
||||
|
||||
- React application with Tailwind CSS
|
||||
- Tree navigation interface
|
||||
- Session management UI
|
||||
- Admin panel for tree creation/editing
|
||||
**Run Seed Script**:
|
||||
```bash
|
||||
cd backend
|
||||
python -m scripts.seed_data
|
||||
```
|
||||
|
||||
### Remaining Work
|
||||
|
||||
1. **Testing**: End-to-end testing of full workflow
|
||||
2. **Polish**: UI refinements, error handling improvements
|
||||
3. **More Trees**: Add remaining 4 trees from `TS-EXAMPLES.md`
|
||||
4. **Deployment**: Set up CI/CD pipeline and deploy to Render/Railway
|
||||
|
||||
---
|
||||
|
||||
@@ -333,22 +437,22 @@ backend/
|
||||
| `05-QUESTIONS-AND-ACTION-ITEMS.md` | Design decisions and priorities |
|
||||
| `TS-EXAMPLES.md` | 5 example troubleshooting trees to implement |
|
||||
| `backend/README.md` | Backend setup instructions |
|
||||
| `backend/.env.example` | Environment variable template |
|
||||
| `frontend/.env.example` | Frontend environment template |
|
||||
|
||||
---
|
||||
|
||||
## Notes for Next Session
|
||||
|
||||
- ✅ Backend **fully tested** - all 18 endpoints working correctly
|
||||
- ✅ **Critical bugs fixed** - DateTime handling, logging, error tracking, role management
|
||||
- ✅ **Integration tests** - 29 tests with full coverage (all passing)
|
||||
- ⏳ **No seed data** created yet - trees table is empty (Phase 1b)
|
||||
- ⏳ **Frontend work** has not started (Phase 2)
|
||||
- ✅ **Seed script created** with Password Reset tree (full implementation)
|
||||
- ✅ **Frontend COMPLETE** - Full React app with all core pages
|
||||
- ✅ **Full workflow tested** - Register → Login → Browse → Navigate → Complete → Export all working
|
||||
- 📝 **Single-user focus** for MVP (team features are in schema but low priority)
|
||||
|
||||
### Recommended Next Steps
|
||||
|
||||
1. **Phase 1b**: Create seed data script with 5 example trees from `TS-EXAMPLES.md`
|
||||
2. **Phase 2**: Begin React frontend development with Tailwind CSS
|
||||
3. **Optional**: Add more advanced logging (structured JSON logs for production)
|
||||
4. **Optional**: Set up CI/CD pipeline with automated testing
|
||||
1. **Polish UI**: Add loading states, better error messages, keyboard shortcuts
|
||||
2. **Add more trees**: Implement remaining 4 trees from `TS-EXAMPLES.md`
|
||||
3. **Deploy**: Set up CI/CD pipeline and deploy to Render/Railway
|
||||
4. **Mobile responsiveness**: Optimize for tablet/mobile use
|
||||
|
||||
@@ -138,14 +138,8 @@ async def update_session(
|
||||
detail="Cannot update a completed session"
|
||||
)
|
||||
|
||||
update_data = session_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Convert DecisionRecord objects to dicts if present
|
||||
if "decisions" in update_data and update_data["decisions"]:
|
||||
update_data["decisions"] = [
|
||||
d.model_dump() if hasattr(d, 'model_dump') else d
|
||||
for d in update_data["decisions"]
|
||||
]
|
||||
# Use mode='json' to ensure datetime fields are serialized as ISO strings for JSONB storage
|
||||
update_data = session_data.model_dump(exclude_unset=True, mode='json')
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(session, field, value)
|
||||
|
||||
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
1
backend/scripts/__init__.py
Normal file
1
backend/scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Apoklisis scripts package
|
||||
473
backend/scripts/seed_data.py
Normal file
473
backend/scripts/seed_data.py
Normal file
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed data script for Apoklisis decision trees.
|
||||
|
||||
This script creates example troubleshooting trees in the database.
|
||||
Run from the backend directory with: python -m scripts.seed_data
|
||||
|
||||
Requirements:
|
||||
- Backend server must be running (uvicorn app.main:app)
|
||||
- Or run with --direct flag to insert directly to database
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import httpx
|
||||
from typing import Any
|
||||
|
||||
|
||||
# API Configuration
|
||||
API_BASE_URL = "http://localhost:8000/api/v1"
|
||||
|
||||
# Default admin user for seeding (will be created if doesn't exist)
|
||||
SEED_USER = {
|
||||
"email": "seed.admin@example.com",
|
||||
"password": "SeedAdmin123!",
|
||||
"name": "Seed Admin",
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TREE DEFINITIONS
|
||||
# =============================================================================
|
||||
|
||||
def get_password_reset_tree() -> dict[str, Any]:
|
||||
"""
|
||||
Password Reset Request - Simple troubleshooting tree.
|
||||
Based on TS-EXAMPLES.md Scenario 5.
|
||||
"""
|
||||
return {
|
||||
"name": "Password Reset Request",
|
||||
"description": "Guide for handling user password reset requests safely and efficiently. Covers identity verification, account status checks, and common issues.",
|
||||
"category": "Account Management",
|
||||
"tree_structure": {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Can you verify the user's identity?",
|
||||
"help_text": "Check against company verification policy (phone callback, email verification, or manager confirmation)",
|
||||
"options": [
|
||||
{"id": "verified", "label": "Yes, identity verified", "next_node_id": "find_account"},
|
||||
{"id": "not_verified", "label": "No, cannot verify", "next_node_id": "deny_request"},
|
||||
{"id": "contractor", "label": "User is a contractor", "next_node_id": "contractor_approval"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "deny_request",
|
||||
"type": "solution",
|
||||
"title": "Deny Request - Cannot Verify Identity",
|
||||
"description": "Inform the user that you cannot process their request without proper identity verification.\n\n**Actions:**\n- Politely explain the verification requirements\n- Provide alternative verification methods they can use\n- Document the denied request in the ticket system\n\n**Do NOT reset the password under any circumstances.**"
|
||||
},
|
||||
{
|
||||
"id": "contractor_approval",
|
||||
"type": "decision",
|
||||
"question": "Does the contractor's manager approve the reset?",
|
||||
"help_text": "Per company policy, contractor password resets require manager approval",
|
||||
"options": [
|
||||
{"id": "manager_approves", "label": "Manager approves", "next_node_id": "find_account"},
|
||||
{"id": "manager_denies", "label": "Manager denies", "next_node_id": "contractor_denied"},
|
||||
{"id": "manager_unavailable", "label": "Cannot reach manager", "next_node_id": "escalate_manager"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "contractor_denied",
|
||||
"type": "solution",
|
||||
"title": "Request Denied by Manager",
|
||||
"description": "Inform the contractor that their manager has denied the password reset request.\n\n**Actions:**\n- Document the denial in the ticket\n- Advise contractor to follow up with their manager directly\n- Close the ticket as 'Denied'"
|
||||
},
|
||||
{
|
||||
"id": "escalate_manager",
|
||||
"type": "solution",
|
||||
"title": "Escalate to IT Manager",
|
||||
"description": "Cannot reach contractor's manager for approval.\n\n**Actions:**\n- Escalate ticket to IT Manager for decision\n- Document attempts to reach the contractor's manager\n- Set ticket status to 'Pending Approval'\n\n**Do NOT reset the password until approval is received.**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "find_account",
|
||||
"type": "decision",
|
||||
"question": "Can you find the user's account in Active Directory?",
|
||||
"help_text": "Search AD by username, email, or employee name",
|
||||
"options": [
|
||||
{"id": "account_found", "label": "Account found", "next_node_id": "check_account_status"},
|
||||
{"id": "account_not_found", "label": "Account not found", "next_node_id": "check_spelling"},
|
||||
{"id": "multiple_accounts", "label": "Multiple accounts found", "next_node_id": "identify_account"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "check_spelling",
|
||||
"type": "decision",
|
||||
"question": "Is the username spelled correctly?",
|
||||
"help_text": "Verify spelling with the user, check for common variations",
|
||||
"options": [
|
||||
{"id": "found_correct", "label": "Found with correct spelling", "next_node_id": "check_account_status"},
|
||||
{"id": "still_not_found", "label": "Still not found", "next_node_id": "no_account_exists"},
|
||||
{"id": "user_no_account", "label": "User doesn't have an account", "next_node_id": "no_account_exists"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "no_account_exists",
|
||||
"type": "solution",
|
||||
"title": "Account Does Not Exist",
|
||||
"description": "The user does not have an Active Directory account.\n\n**Actions:**\n- Verify with HR if user should have an account\n- If new employee: Route to New User Request process\n- If existing employee: Escalate to AD team for investigation\n- Document findings in the ticket"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "identify_account",
|
||||
"type": "decision",
|
||||
"question": "Can you identify the correct account?",
|
||||
"help_text": "Use employee ID, department, manager name, or start date to identify",
|
||||
"options": [
|
||||
{"id": "identified_by_id", "label": "Identified by employee ID", "next_node_id": "check_account_status"},
|
||||
{"id": "identified_by_dept", "label": "Identified by department", "next_node_id": "check_account_status"},
|
||||
{"id": "cannot_identify", "label": "Cannot identify correct account", "next_node_id": "need_more_info"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "need_more_info",
|
||||
"type": "solution",
|
||||
"title": "Request Additional Information",
|
||||
"description": "Cannot determine which account belongs to the user.\n\n**Actions:**\n- Ask user for:\n - Employee ID\n - Department\n - Manager's name\n - Hire/start date\n - Last known working username\n- Once identified, continue with account status check"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "check_account_status",
|
||||
"type": "decision",
|
||||
"question": "What is the account status?",
|
||||
"help_text": "Check if account is enabled, disabled, or locked in AD",
|
||||
"options": [
|
||||
{"id": "enabled", "label": "Account is enabled", "next_node_id": "reset_password"},
|
||||
{"id": "disabled", "label": "Account is disabled", "next_node_id": "check_disabled_reason"},
|
||||
{"id": "locked", "label": "Account is locked out", "next_node_id": "unlock_account"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "check_disabled_reason",
|
||||
"type": "decision",
|
||||
"question": "Why is the account disabled?",
|
||||
"help_text": "Check account notes, ticket history, or HR records",
|
||||
"options": [
|
||||
{"id": "terminated", "label": "Disabled for termination", "next_node_id": "terminated_user"},
|
||||
{"id": "inactive", "label": "Disabled for inactivity", "next_node_id": "verify_employment"},
|
||||
{"id": "disabled_error", "label": "Disabled in error", "next_node_id": "enable_and_reset"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "terminated_user",
|
||||
"type": "solution",
|
||||
"title": "Account Disabled - User Terminated",
|
||||
"description": "**DO NOT ENABLE THIS ACCOUNT**\n\nThe account was disabled because the user has been terminated.\n\n**Actions:**\n- Inform the requester that the account cannot be enabled\n- If this is an error, direct them to HR to verify employment status\n- Document the request and denial in the ticket\n- Close ticket as 'Denied - Terminated User'"
|
||||
},
|
||||
{
|
||||
"id": "verify_employment",
|
||||
"type": "decision",
|
||||
"question": "Is the user still employed?",
|
||||
"help_text": "Verify with HR or the user's manager",
|
||||
"options": [
|
||||
{"id": "still_employed", "label": "Still employed", "next_node_id": "enable_and_reset"},
|
||||
{"id": "terminated_confirmed", "label": "Terminated", "next_node_id": "terminated_user"},
|
||||
{"id": "unknown_status", "label": "Unknown status", "next_node_id": "escalate_employment"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "escalate_employment",
|
||||
"type": "solution",
|
||||
"title": "Escalate to IT Manager",
|
||||
"description": "Cannot verify employment status.\n\n**Actions:**\n- Escalate ticket to IT Manager\n- Document all verification attempts\n- Do NOT enable the account until status is confirmed"
|
||||
},
|
||||
{
|
||||
"id": "enable_and_reset",
|
||||
"type": "action",
|
||||
"title": "Enable Account and Reset Password",
|
||||
"description": "Account should be re-enabled.\n\n**Actions:**\n1. Enable the account in Active Directory\n2. Continue to password reset step",
|
||||
"next_node_id": "reset_password"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unlock_account",
|
||||
"type": "action",
|
||||
"title": "Unlock Account",
|
||||
"description": "Account is locked out, likely from too many failed login attempts.\n\n**Actions:**\n1. Unlock the account in Active Directory\n2. Continue to reset password",
|
||||
"action_command": "Unlock-ADAccount -Identity USERNAME",
|
||||
"next_node_id": "reset_password"
|
||||
},
|
||||
{
|
||||
"id": "reset_password",
|
||||
"type": "decision",
|
||||
"question": "Were you able to reset the password?",
|
||||
"help_text": "Set a temporary password in AD with 'User must change password at next logon' checked",
|
||||
"action_command": "Set-ADAccountPassword -Identity USERNAME -Reset -NewPassword (ConvertTo-SecureString 'TempPass123!' -AsPlainText -Force)",
|
||||
"options": [
|
||||
{"id": "reset_success", "label": "Reset successful", "next_node_id": "communicate_password"},
|
||||
{"id": "reset_denied", "label": "Permission denied", "next_node_id": "escalate_reset"},
|
||||
{"id": "reset_but_fails", "label": "Reset but user still can't login", "next_node_id": "check_login_issue"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "escalate_reset",
|
||||
"type": "solution",
|
||||
"title": "Escalate to Higher-Level Admin",
|
||||
"description": "You don't have permission to reset this user's password.\n\n**This typically happens for:**\n- Executive accounts\n- Service accounts\n- Accounts in protected OUs\n\n**Actions:**\n- Escalate to Tier 2 or AD Administrator\n- Document the account and reason for escalation"
|
||||
},
|
||||
{
|
||||
"id": "check_login_issue",
|
||||
"type": "decision",
|
||||
"question": "What is preventing login?",
|
||||
"help_text": "Common issues after password reset",
|
||||
"options": [
|
||||
{"id": "wrong_username", "label": "User entering wrong username", "next_node_id": "provide_username"},
|
||||
{"id": "caps_lock", "label": "Caps Lock is on", "next_node_id": "caps_lock_fix"},
|
||||
{"id": "not_synced", "label": "Password not synced yet", "next_node_id": "wait_sync"},
|
||||
{"id": "mfa_issue", "label": "MFA/2FA issue", "next_node_id": "mfa_separate"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "provide_username",
|
||||
"type": "solution",
|
||||
"title": "Provide Correct Username",
|
||||
"description": "User is entering the wrong username.\n\n**Actions:**\n- Provide the correct username format (e.g., jsmith or john.smith@company.com)\n- Have user try again with correct credentials\n- Verify successful login"
|
||||
},
|
||||
{
|
||||
"id": "caps_lock_fix",
|
||||
"type": "solution",
|
||||
"title": "Caps Lock Issue",
|
||||
"description": "User has Caps Lock enabled.\n\n**Actions:**\n- Inform user to turn off Caps Lock\n- Have user retry login\n- Verify successful login"
|
||||
},
|
||||
{
|
||||
"id": "wait_sync",
|
||||
"type": "solution",
|
||||
"title": "Wait for Password Sync",
|
||||
"description": "Password may not have synchronized to all systems yet.\n\n**Actions:**\n- Wait 2-5 minutes for AD replication\n- If using Azure AD Connect, sync may take up to 30 minutes\n- Have user retry after waiting\n- If still failing after 30 minutes, check replication status"
|
||||
},
|
||||
{
|
||||
"id": "mfa_separate",
|
||||
"type": "solution",
|
||||
"title": "MFA Issue - Separate Troubleshooting",
|
||||
"description": "User has a Multi-Factor Authentication issue.\n\n**This is a separate troubleshooting path.**\n\n**Common MFA issues:**\n- Authenticator app not set up\n- Phone number changed\n- Hardware token lost\n\n**Actions:**\n- Create a new ticket for MFA troubleshooting\n- Or escalate to Identity Management team"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "communicate_password",
|
||||
"type": "decision",
|
||||
"question": "How was the password communicated to the user?",
|
||||
"help_text": "Ensure secure delivery of temporary password",
|
||||
"options": [
|
||||
{"id": "told_phone", "label": "Told over phone", "next_node_id": "verify_login"},
|
||||
{"id": "secure_portal", "label": "Sent via secure portal", "next_node_id": "verify_login"},
|
||||
{"id": "user_received", "label": "User confirmed receipt", "next_node_id": "verify_login"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "verify_login",
|
||||
"type": "decision",
|
||||
"question": "Was the user able to log in successfully?",
|
||||
"help_text": "Confirm with user that they can access their account",
|
||||
"options": [
|
||||
{"id": "login_success", "label": "Login successful", "next_node_id": "resolution_success"},
|
||||
{"id": "login_failed", "label": "Still cannot log in", "next_node_id": "check_login_issue"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "resolution_success",
|
||||
"type": "solution",
|
||||
"title": "✅ Password Reset Complete",
|
||||
"description": "User has successfully logged in with their new password.\n\n**Final Steps:**\n1. Confirm user was prompted to change their password\n2. Verify user successfully set a new personal password\n3. Document the resolution in the ticket\n4. Close the ticket as 'Resolved'\n\n**Resolution Indicators:**\n- User confirms successful login\n- Account shows updated 'Last Logon' timestamp\n- No subsequent lockouts or reset requests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_file_share_access_tree() -> dict[str, Any]:
|
||||
"""
|
||||
User Cannot Access File Share - Medium complexity troubleshooting tree.
|
||||
Based on TS-EXAMPLES.md Scenario 3.
|
||||
(Stub for future implementation)
|
||||
"""
|
||||
return {
|
||||
"name": "File Share Access Issues",
|
||||
"description": "Troubleshoot user access to network file shares. Covers network connectivity, permissions, and SMB issues.",
|
||||
"category": "File Services",
|
||||
"tree_structure": {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Can the user ping the file server by name?",
|
||||
"options": [
|
||||
{"id": "ping_success", "label": "Yes, ping succeeds", "next_node_id": "check_share_access"},
|
||||
{"id": "ping_timeout", "label": "No, request timed out", "next_node_id": "network_issue"},
|
||||
{"id": "unknown_host", "label": "Unknown host error", "next_node_id": "dns_issue"}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "check_share_access",
|
||||
"type": "solution",
|
||||
"title": "Check Share Access (Placeholder)",
|
||||
"description": "This tree will be expanded in a future update.\n\nFor now, check:\n1. Share permissions\n2. NTFS permissions\n3. User group memberships"
|
||||
},
|
||||
{
|
||||
"id": "network_issue",
|
||||
"type": "solution",
|
||||
"title": "Network Connectivity Issue (Placeholder)",
|
||||
"description": "This tree will be expanded in a future update.\n\nCheck:\n1. VPN status if remote\n2. Network cable/WiFi connection\n3. Firewall rules"
|
||||
},
|
||||
{
|
||||
"id": "dns_issue",
|
||||
"type": "solution",
|
||||
"title": "DNS Resolution Issue (Placeholder)",
|
||||
"description": "This tree will be expanded in a future update.\n\nRun: nslookup FILE-SERVER-NAME\n\nCheck DNS configuration on user's PC."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SEEDING FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
async def get_or_create_admin_user(client: httpx.AsyncClient) -> tuple[str, dict]:
|
||||
"""Get authentication token for seeding. Creates admin user if needed."""
|
||||
|
||||
# Try to login first
|
||||
login_response = await client.post(
|
||||
f"{API_BASE_URL}/auth/login/json",
|
||||
json={"email": SEED_USER["email"], "password": SEED_USER["password"]}
|
||||
)
|
||||
|
||||
if login_response.status_code == 200:
|
||||
token_data = login_response.json()
|
||||
return token_data["access_token"], {"exists": True}
|
||||
|
||||
# User doesn't exist, create them
|
||||
register_response = await client.post(
|
||||
f"{API_BASE_URL}/auth/register",
|
||||
json=SEED_USER
|
||||
)
|
||||
|
||||
if register_response.status_code not in (200, 201):
|
||||
raise Exception(f"Failed to create seed user: {register_response.text}")
|
||||
|
||||
# Now login
|
||||
login_response = await client.post(
|
||||
f"{API_BASE_URL}/auth/login/json",
|
||||
json={"email": SEED_USER["email"], "password": SEED_USER["password"]}
|
||||
)
|
||||
|
||||
if login_response.status_code != 200:
|
||||
raise Exception(f"Failed to login as seed user: {login_response.text}")
|
||||
|
||||
token_data = login_response.json()
|
||||
return token_data["access_token"], {"exists": False, "user": register_response.json()}
|
||||
|
||||
|
||||
async def create_tree(client: httpx.AsyncClient, token: str, tree_data: dict) -> dict:
|
||||
"""Create a tree via the API."""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Check if tree with same name exists
|
||||
list_response = await client.get(f"{API_BASE_URL}/trees", headers=headers)
|
||||
if list_response.status_code == 200:
|
||||
existing_trees = list_response.json()
|
||||
for tree in existing_trees:
|
||||
if tree["name"] == tree_data["name"]:
|
||||
print(f" ⏭️ Tree '{tree_data['name']}' already exists (ID: {tree['id']})")
|
||||
return tree
|
||||
|
||||
# Create the tree
|
||||
response = await client.post(
|
||||
f"{API_BASE_URL}/trees",
|
||||
json=tree_data,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise Exception(f"Failed to create tree '{tree_data['name']}': {response.text}")
|
||||
|
||||
tree = response.json()
|
||||
print(f" ✅ Created tree '{tree_data['name']}' (ID: {tree['id']})")
|
||||
return tree
|
||||
|
||||
|
||||
async def seed_database():
|
||||
"""Main seeding function."""
|
||||
print("\n🌱 Apoklisis Database Seeder")
|
||||
print("=" * 50)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Check if API is running
|
||||
try:
|
||||
health_check = await client.get(f"{API_BASE_URL.replace('/api/v1', '')}/health")
|
||||
except httpx.ConnectError:
|
||||
print("\n❌ Error: Cannot connect to API server")
|
||||
print(f" Make sure the server is running at {API_BASE_URL}")
|
||||
print(" Run: uvicorn app.main:app --reload")
|
||||
return False
|
||||
|
||||
# Get or create admin user
|
||||
print("\n📋 Setting up seed user...")
|
||||
try:
|
||||
token, user_info = await get_or_create_admin_user(client)
|
||||
if user_info.get("exists"):
|
||||
print(" ✅ Using existing seed admin user")
|
||||
else:
|
||||
print(f" ✅ Created seed admin user: {SEED_USER['email']}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Failed to setup seed user: {e}")
|
||||
return False
|
||||
|
||||
# Create trees
|
||||
print("\n🌳 Creating decision trees...")
|
||||
|
||||
trees_to_create = [
|
||||
get_password_reset_tree(),
|
||||
get_file_share_access_tree(),
|
||||
]
|
||||
|
||||
created_trees = []
|
||||
for tree_data in trees_to_create:
|
||||
try:
|
||||
tree = await create_tree(client, token, tree_data)
|
||||
created_trees.append(tree)
|
||||
except Exception as e:
|
||||
print(f" ❌ Failed to create '{tree_data['name']}': {e}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 50)
|
||||
print(f"✅ Seeding complete! Created {len(created_trees)} trees.")
|
||||
print("\nCreated trees:")
|
||||
for tree in created_trees:
|
||||
print(f" - {tree['name']} ({tree['category']})")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Seed the Apoklisis database with example trees")
|
||||
parser.add_argument("--direct", action="store_true", help="Insert directly to database (not implemented)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.direct:
|
||||
print("Direct database insertion not yet implemented. Using API method.")
|
||||
|
||||
success = asyncio.run(seed_database())
|
||||
exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# API URL - defaults to http://localhost:8000 if not set
|
||||
VITE_API_URL=http://localhost:8000
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4823
frontend/package-lock.json
generated
Normal file
4823
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { router } from '@/router'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, fetchUser, setLoading } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
// On app load, check if we have a token and fetch user data
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token && isAuthenticated) {
|
||||
fetchUser().catch(() => {
|
||||
// Token is invalid, will be handled by interceptor
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
35
frontend/src/api/auth.ts
Normal file
35
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import apiClient from './client'
|
||||
import type { Token, User, UserCreate, UserLogin } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
async register(data: UserCreate): Promise<User> {
|
||||
const response = await apiClient.post<User>('/auth/register', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async login(data: UserLogin): Promise<Token> {
|
||||
const response = await apiClient.post<Token>('/auth/login/json', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async refresh(): Promise<Token> {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
const response = await apiClient.post<Token>('/auth/refresh', null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async me(): Promise<User> {
|
||||
const response = await apiClient.get<User>('/auth/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await apiClient.post('/auth/logout')
|
||||
},
|
||||
}
|
||||
|
||||
export default authApi
|
||||
66
frontend/src/api/client.ts
Normal file
66
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - handle token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// If 401 and not already retrying, attempt token refresh
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const { access_token, refresh_token } = response.data
|
||||
localStorage.setItem('access_token', access_token)
|
||||
localStorage.setItem('refresh_token', refresh_token)
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
4
frontend/src/api/index.ts
Normal file
4
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as apiClient } from './client'
|
||||
export { default as authApi } from './auth'
|
||||
export { default as treesApi } from './trees'
|
||||
export { default as sessionsApi } from './sessions'
|
||||
51
frontend/src/api/sessions.ts
Normal file
51
frontend/src/api/sessions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import apiClient from './client'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport } from '@/types'
|
||||
|
||||
export interface SessionListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
tree_id?: string
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
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 })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Session> {
|
||||
const response = await apiClient.get<Session>(`/sessions/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: SessionCreate): Promise<Session> {
|
||||
const response = await apiClient.post<Session>('/sessions', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: SessionUpdate): Promise<Session> {
|
||||
const response = await apiClient.put<Session>(`/sessions/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async complete(id: string): Promise<Session> {
|
||||
const response = await apiClient.post<Session>(`/sessions/${id}/complete`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async export(id: string, options: SessionExport): Promise<string> {
|
||||
const response = await apiClient.post<string>(`/sessions/${id}/export`, options)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default sessionsApi
|
||||
57
frontend/src/api/trees.ts
Normal file
57
frontend/src/api/trees.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import apiClient from './client'
|
||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate } from '@/types'
|
||||
|
||||
export interface TreeListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: string
|
||||
include_inactive?: boolean
|
||||
}
|
||||
|
||||
export interface TreeListResponse {
|
||||
items: TreeListItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export const treesApi = {
|
||||
async list(params?: TreeListParams): Promise<TreeListItem[]> {
|
||||
const response = await apiClient.get<TreeListItem[]>('/trees', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Tree> {
|
||||
const response = await apiClient.get<Tree>(`/trees/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: TreeCreate): Promise<Tree> {
|
||||
const response = await apiClient.post<Tree>('/trees', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: TreeUpdate): Promise<Tree> {
|
||||
const response = await apiClient.put<Tree>(`/trees/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/trees/${id}`)
|
||||
},
|
||||
|
||||
async categories(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/trees/categories')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async search(query: string, category?: string): Promise<TreeListItem[]> {
|
||||
const response = await apiClient.get<TreeListItem[]>('/trees/search', {
|
||||
params: { q: query, category },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default treesApi
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
66
frontend/src/components/common/ErrorBoundary.tsx
Normal file
66
frontend/src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="mb-2 text-xl font-semibold text-foreground">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre className="mb-4 overflow-auto rounded bg-muted p-3 text-left text-xs text-muted-foreground">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
52
frontend/src/components/common/RouteError.tsx
Normal file
52
frontend/src/components/common/RouteError.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function RouteError() {
|
||||
const error = useRouteError()
|
||||
const navigate = useNavigate()
|
||||
|
||||
let errorMessage = 'An unexpected error occurred'
|
||||
let errorDetails = ''
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
errorMessage = error.status === 404 ? 'Page not found' : `Error ${error.status}`
|
||||
errorDetails = error.statusText || ''
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = 'Something went wrong'
|
||||
errorDetails = error.message
|
||||
}
|
||||
|
||||
return (
|
||||
<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-foreground">{errorMessage}</h2>
|
||||
{errorDetails && (
|
||||
<p className="mb-4 text-muted-foreground">{errorDetails}</p>
|
||||
)}
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className={cn(
|
||||
'rounded-md border border-input px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/trees')}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteError
|
||||
72
frontend/src/components/layout/AppLayout.tsx
Normal file
72
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/trees', label: 'Trees' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card">
|
||||
<div className="container mx-auto flex h-14 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/trees" className="text-lg font-bold text-foreground">
|
||||
Apoklisis
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-1 sm:flex">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
location.pathname.startsWith(item.path)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="hidden text-sm text-muted-foreground sm:block">
|
||||
{user?.name || user?.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppLayout
|
||||
27
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
27
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default ProtectedRoute
|
||||
2
frontend/src/components/layout/index.ts
Normal file
2
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AppLayout } from './AppLayout'
|
||||
export { default as ProtectedRoute } from './ProtectedRoute'
|
||||
60
frontend/src/index.css
Normal file
60
frontend/src/index.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
118
frontend/src/pages/LoginPage.tsx
Normal file
118
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { login, isLoading, error, clearError } = useAuthStore()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/trees'
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLocalError('')
|
||||
clearError()
|
||||
|
||||
if (!email || !password) {
|
||||
setLocalError('Please enter both email and password')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await login({ email, password })
|
||||
navigate(from, { replace: true })
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Apoklisis</h1>
|
||||
<p className="mt-2 text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
{(error || localError) && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{localError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-medium text-primary hover:text-primary/90">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
172
frontend/src/pages/RegisterPage.tsx
Normal file
172
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const { register, isLoading, error, clearError } = useAuthStore()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLocalError('')
|
||||
clearError()
|
||||
|
||||
if (!name || !email || !password) {
|
||||
setLocalError('Please fill in all fields')
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 10) {
|
||||
setLocalError('Password must be at least 10 characters')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await register({ email, password, name })
|
||||
navigate('/trees', { replace: true })
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Apoklisis</h1>
|
||||
<p className="mt-2 text-muted-foreground">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
{(error || localError) && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{localError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
placeholder="John Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Must be at least 10 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-primary hover:text-primary/90">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterPage
|
||||
206
frontend/src/pages/SessionDetailPage.tsx
Normal file
206
frontend/src/pages/SessionDetailPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { sessionsApi } from '@/api'
|
||||
import type { Session, SessionExport } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SessionDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>('markdown')
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadSession()
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const loadSession = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await sessionsApi.get(id!)
|
||||
setSession(data)
|
||||
} catch (err) {
|
||||
setError('Failed to load session')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!session) return
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const options: SessionExport = {
|
||||
format: exportFormat,
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
}
|
||||
const content = await sessionsApi.export(session.id, options)
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `session-${session.ticket_number || session.id}.${exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt'}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err)
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
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-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{error || 'Session not found'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="mt-4 text-primary hover:underline"
|
||||
>
|
||||
Back to sessions
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="mb-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to sessions
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
{session.ticket_number || 'Session Details'}
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
session.completed_at ? 'text-green-600' : 'text-yellow-600'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
|
||||
)}
|
||||
/>
|
||||
{session.completed_at ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
{session.client_name && <span>Client: {session.client_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Decision Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-primary" />
|
||||
<span className="text-muted-foreground">
|
||||
Session started: {formatDate(session.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.decisions.map((decision, index) => (
|
||||
<div key={index} className="ml-1 border-l-2 border-border pl-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-border" />
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-card-foreground">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-primary">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-muted/50 p-2 text-sm text-muted-foreground">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{session.completed_at && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-green-600">
|
||||
Session completed: {formatDate(session.completed_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionDetailPage
|
||||
152
frontend/src/pages/SessionHistoryPage.tsx
Normal file
152
frontend/src/pages/SessionHistoryPage.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { sessionsApi } from '@/api'
|
||||
import type { Session } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SessionHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [filter])
|
||||
|
||||
const loadSessions = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = filter === 'all' ? {} : { completed: filter === 'completed' }
|
||||
const sessionsData = await sessionsApi.list(params)
|
||||
setSessions(sessionsData)
|
||||
} catch (err) {
|
||||
setError('Failed to load sessions')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Session History</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
View and manage your troubleshooting sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="mb-6 flex gap-2 border-b border-border">
|
||||
{(['all', 'active', 'completed'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setFilter(tab)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors',
|
||||
filter === tab
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No sessions found.{' '}
|
||||
<button
|
||||
onClick={() => navigate('/trees')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Start a new session
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="rounded-lg border border-border bg-card p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2 w-2 rounded-full',
|
||||
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-card-foreground">
|
||||
{session.ticket_number || 'No ticket'}
|
||||
</span>
|
||||
{session.client_name && (
|
||||
<span className="text-muted-foreground">
|
||||
· {session.client_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Started: {formatDate(session.started_at)}
|
||||
{session.completed_at && (
|
||||
<> · Completed: {formatDate(session.completed_at)}</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{session.decisions.length} decisions recorded
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
className={cn(
|
||||
'rounded-md border border-input px-3 py-1.5 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{!session.completed_at && (
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionHistoryPage
|
||||
168
frontend/src/pages/TreeLibraryPage.tsx
Normal file
168
frontend/src/pages/TreeLibraryPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { treesApi } from '@/api'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TreeLibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
const [categories, setCategories] = useState<string[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [selectedCategory])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [treesData, categoriesData] = await Promise.all([
|
||||
treesApi.list({ category: selectedCategory || undefined }),
|
||||
treesApi.categories(),
|
||||
])
|
||||
setTrees(treesData)
|
||||
setCategories(categoriesData)
|
||||
} catch (err) {
|
||||
setError('Failed to load trees')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
loadData()
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const results = await treesApi.search(searchQuery, selectedCategory || undefined)
|
||||
setTrees(results)
|
||||
} catch (err) {
|
||||
setError('Search failed')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartSession = (treeId: string) => {
|
||||
navigate(`/trees/${treeId}/navigate`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Select a troubleshooting tree to start a new session
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No trees found. {searchQuery && 'Try adjusting your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="rounded-lg border border-border bg-card p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
{tree.category && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeLibraryPage
|
||||
484
frontend/src/pages/TreeNavigationPage.tsx
Normal file
484
frontend/src/pages/TreeNavigationPage.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { treesApi, sessionsApi } from '@/api'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export function TreeNavigationPage() {
|
||||
const { id: treeId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locationState = location.state as LocationState | undefined
|
||||
|
||||
const [tree, setTree] = useState<Tree | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
||||
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||
const [notes, setNotes] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
|
||||
// Session metadata
|
||||
const [ticketNumber, setTicketNumber] = useState<string>('')
|
||||
const [clientName, setClientName] = useState<string>('')
|
||||
const [showMetadataForm, setShowMetadataForm] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (treeId) {
|
||||
loadTreeAndSession()
|
||||
}
|
||||
}, [treeId])
|
||||
|
||||
const loadTreeAndSession = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const treeData = await treesApi.get(treeId!)
|
||||
setTree(treeData)
|
||||
|
||||
// If resuming a session
|
||||
if (locationState?.sessionId) {
|
||||
const sessionData = await sessionsApi.get(locationState.sessionId)
|
||||
setSession(sessionData)
|
||||
setPathTaken(sessionData.path_taken)
|
||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
||||
setDecisions(sessionData.decisions as DecisionRecord[])
|
||||
setTicketNumber(sessionData.ticket_number || '')
|
||||
setClientName(sessionData.client_name || '')
|
||||
setShowMetadataForm(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load tree')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startSession = async () => {
|
||||
if (!tree) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const newSession = await sessionsApi.create({
|
||||
tree_id: tree.id,
|
||||
ticket_number: ticketNumber || undefined,
|
||||
client_name: clientName || undefined,
|
||||
})
|
||||
setSession(newSession)
|
||||
setShowMetadataForm(false)
|
||||
} catch (err) {
|
||||
setError('Failed to start session')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const findNode = (nodeId: string, structure: TreeStructure = tree?.tree_structure!): TreeStructure | null => {
|
||||
if (structure.id === nodeId) return structure
|
||||
if (structure.children) {
|
||||
for (const child of structure.children) {
|
||||
const found = findNode(nodeId, child)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
|
||||
if (!session || !tree) return
|
||||
|
||||
const currentNode = findNode(currentNodeId)
|
||||
if (!currentNode) return
|
||||
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: currentNode.question || null,
|
||||
answer: optionLabel,
|
||||
action_performed: null,
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
const newPath = [...pathTaken, nextNodeId]
|
||||
const newDecisions = [...decisions, newDecision]
|
||||
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(nextNodeId)
|
||||
setNotes('')
|
||||
|
||||
// Update session on backend
|
||||
try {
|
||||
await sessionsApi.update(session.id, {
|
||||
path_taken: newPath,
|
||||
decisions: newDecisions,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update session:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContinue = async (actionPerformed?: string) => {
|
||||
if (!session || !tree) return
|
||||
|
||||
const currentNode = findNode(currentNodeId)
|
||||
if (!currentNode || !currentNode.next_node_id) return
|
||||
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: actionPerformed || currentNode.title || 'Action completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
const newPath = [...pathTaken, currentNode.next_node_id]
|
||||
const newDecisions = [...decisions, newDecision]
|
||||
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(currentNode.next_node_id)
|
||||
setNotes('')
|
||||
|
||||
try {
|
||||
await sessionsApi.update(session.id, {
|
||||
path_taken: newPath,
|
||||
decisions: newDecisions,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update session:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!session) return
|
||||
setIsCompleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Add final decision
|
||||
const currentNode = findNode(currentNodeId)
|
||||
if (currentNode) {
|
||||
const finalDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: currentNode.title || 'Session completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
await sessionsApi.update(session.id, {
|
||||
decisions: [...decisions, finalDecision],
|
||||
})
|
||||
}
|
||||
|
||||
await sessionsApi.complete(session.id)
|
||||
navigate(`/sessions/${session.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to complete session:', err)
|
||||
setError('Failed to complete session. Check console for details.')
|
||||
} finally {
|
||||
setIsCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (pathTaken.length <= 1) return
|
||||
const newPath = pathTaken.slice(0, -1)
|
||||
const newDecisions = decisions.slice(0, -1)
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(newPath[newPath.length - 1])
|
||||
}
|
||||
|
||||
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-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !tree) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{error || 'Tree not found'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/trees')}
|
||||
className="mt-4 text-primary hover:underline"
|
||||
>
|
||||
Back to trees
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Session metadata form
|
||||
if (showMetadataForm) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-lg px-4 py-8">
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">{tree.name}</h1>
|
||||
<p className="mb-6 text-muted-foreground">{tree.description}</p>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="font-semibold text-card-foreground">Session Details</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional: Add ticket and client info for easier tracking
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Ticket Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ticketNumber}
|
||||
onChange={(e) => setTicketNumber(e.target.value)}
|
||||
placeholder="e.g., INC0012345"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Client Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="e.g., Acme Corp"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={startSession}
|
||||
className={cn(
|
||||
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Start Troubleshooting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentNode = findNode(currentNodeId)
|
||||
|
||||
if (!currentNode) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
Invalid tree structure
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">{tree.name}</h1>
|
||||
{(ticketNumber || clientName) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ticketNumber && `Ticket: ${ticketNumber}`}
|
||||
{ticketNumber && clientName && ' · '}
|
||||
{clientName && `Client: ${clientName}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm">
|
||||
{pathTaken.map((nodeId, index) => {
|
||||
const node = findNode(nodeId)
|
||||
return (
|
||||
<span key={nodeId} className="flex items-center gap-2 whitespace-nowrap">
|
||||
{index > 0 && <span className="text-muted-foreground">→</span>}
|
||||
<span
|
||||
className={cn(
|
||||
index === pathTaken.length - 1
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{node?.question?.slice(0, 30) || node?.title?.slice(0, 30) || nodeId}
|
||||
{((node?.question?.length || 0) > 30 || (node?.title?.length || 0) > 30) && '...'}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Current Node */}
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
{/* Decision Node */}
|
||||
{currentNode.type === 'decision' && (
|
||||
<>
|
||||
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
|
||||
{currentNode.question}
|
||||
</h2>
|
||||
{currentNode.help_text && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">{currentNode.help_text}</p>
|
||||
)}
|
||||
<div className="mb-4 space-y-2">
|
||||
{currentNode.options?.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleSelectOption(option.id, option.label, option.next_node_id)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input p-3 text-left transition-colors',
|
||||
'hover:border-primary hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Node */}
|
||||
{currentNode.type === 'action' && (
|
||||
<>
|
||||
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
|
||||
{currentNode.title}
|
||||
</h2>
|
||||
<p className="mb-4 text-muted-foreground">{currentNode.description}</p>
|
||||
{currentNode.commands && currentNode.commands.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Commands:</p>
|
||||
<div className="space-y-1">
|
||||
{currentNode.commands.map((cmd, index) => (
|
||||
<code
|
||||
key={index}
|
||||
className="block rounded bg-muted p-2 text-sm font-mono"
|
||||
>
|
||||
{cmd}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentNode.expected_outcome && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
<strong>Expected outcome:</strong> {currentNode.expected_outcome}
|
||||
</p>
|
||||
)}
|
||||
{currentNode.next_node_id && (
|
||||
<button
|
||||
onClick={() => handleContinue()}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Solution Node */}
|
||||
{currentNode.type === 'solution' && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||
Solution
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
|
||||
{currentNode.title}
|
||||
</h2>
|
||||
<p className="mb-4 text-muted-foreground">{currentNode.description}</p>
|
||||
{currentNode.resolution_steps && currentNode.resolution_steps.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Resolution steps:</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-sm text-muted-foreground">
|
||||
{currentNode.resolution_steps.map((step, index) => (
|
||||
<li key={index}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={isCompleting}
|
||||
className={cn(
|
||||
'rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
|
||||
'hover:bg-green-700 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isCompleting ? 'Completing...' : 'Complete Session'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any notes for this step..."
|
||||
rows={2}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Back Button */}
|
||||
{pathTaken.length > 1 && (
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="mt-4 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Go back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeNavigationPage
|
||||
6
frontend/src/pages/index.ts
Normal file
6
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as LoginPage } from './LoginPage'
|
||||
export { default as RegisterPage } from './RegisterPage'
|
||||
export { default as TreeLibraryPage } from './TreeLibraryPage'
|
||||
export { default as TreeNavigationPage } from './TreeNavigationPage'
|
||||
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
||||
export { default as SessionDetailPage } from './SessionDetailPage'
|
||||
57
frontend/src/router.tsx
Normal file
57
frontend/src/router.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { AppLayout, ProtectedRoute } from '@/components/layout'
|
||||
import { RouteError } from '@/components/common/RouteError'
|
||||
import {
|
||||
LoginPage,
|
||||
RegisterPage,
|
||||
TreeLibraryPage,
|
||||
TreeNavigationPage,
|
||||
SessionHistoryPage,
|
||||
SessionDetailPage,
|
||||
} from '@/pages'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: <RegisterPage />,
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/trees" replace />,
|
||||
},
|
||||
{
|
||||
path: 'trees',
|
||||
element: <TreeLibraryPage />,
|
||||
},
|
||||
{
|
||||
path: 'trees/:id/navigate',
|
||||
element: <TreeNavigationPage />,
|
||||
},
|
||||
{
|
||||
path: 'sessions',
|
||||
element: <SessionHistoryPage />,
|
||||
},
|
||||
{
|
||||
path: 'sessions/:id',
|
||||
element: <SessionDetailPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
export default router
|
||||
101
frontend/src/store/authStore.ts
Normal file
101
frontend/src/store/authStore.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User, Token, UserCreate, UserLogin } from '@/types'
|
||||
import { authApi } from '@/api'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: Token | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
login: (credentials: UserLogin) => Promise<void>
|
||||
register: (data: UserCreate) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
fetchUser: () => Promise<void>
|
||||
clearError: () => void
|
||||
setLoading: (loading: boolean) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (credentials: UserLogin) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const token = await authApi.login(credentials)
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem('access_token', token.access_token)
|
||||
localStorage.setItem('refresh_token', token.refresh_token)
|
||||
|
||||
set({ token, isAuthenticated: true })
|
||||
|
||||
// Fetch user info
|
||||
await get().fetchUser()
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Login failed'
|
||||
set({ error: message, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
register: async (data: UserCreate) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await authApi.register(data)
|
||||
// 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'
|
||||
set({ error: message, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
set({ user: null, token: null, isAuthenticated: false, error: null })
|
||||
}
|
||||
},
|
||||
|
||||
fetchUser: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const user = await authApi.me()
|
||||
set({ user, isLoading: false })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
||||
set({ error: message, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
export default useAuthStore
|
||||
12
frontend/src/types/auth.ts
Normal file
12
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface Token {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: import('./user').User | null
|
||||
token: Token | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
17
frontend/src/types/index.ts
Normal file
17
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export * from './user'
|
||||
export * from './auth'
|
||||
export * from './tree'
|
||||
export * from './session'
|
||||
|
||||
// API response wrapper types
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail: string
|
||||
}
|
||||
55
frontend/src/types/session.ts
Normal file
55
frontend/src/types/session.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { TreeStructure } from './tree'
|
||||
|
||||
export interface DecisionRecord {
|
||||
node_id: string
|
||||
question: string | null
|
||||
answer: string | null
|
||||
action_performed: string | null
|
||||
notes: string | null
|
||||
automation_used: boolean
|
||||
timestamp: string
|
||||
attachments: string[]
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
tree_id: string
|
||||
user_id: string
|
||||
tree_snapshot: TreeStructure
|
||||
path_taken: string[]
|
||||
decisions: DecisionRecord[]
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
ticket_number: string | null
|
||||
client_name: string | null
|
||||
exported: boolean
|
||||
}
|
||||
|
||||
export interface SessionCreate {
|
||||
tree_id: string
|
||||
ticket_number?: string
|
||||
client_name?: string
|
||||
}
|
||||
|
||||
export interface SessionUpdate {
|
||||
path_taken?: string[]
|
||||
decisions?: DecisionRecord[]
|
||||
ticket_number?: string
|
||||
client_name?: string
|
||||
}
|
||||
|
||||
export interface SessionExport {
|
||||
format: 'text' | 'markdown' | 'html'
|
||||
include_timestamps?: boolean
|
||||
include_tree_info?: boolean
|
||||
}
|
||||
|
||||
// Navigation state for active session
|
||||
export interface SessionNavigationState {
|
||||
activeSession: Session | null
|
||||
currentNodeId: string
|
||||
pathTaken: string[]
|
||||
decisions: DecisionRecord[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
98
frontend/src/types/tree.ts
Normal file
98
frontend/src/types/tree.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Tree node types
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
|
||||
export interface TreeOption {
|
||||
id: string
|
||||
label: string
|
||||
next_node_id: string
|
||||
}
|
||||
|
||||
export interface TreeNodeBase {
|
||||
id: string
|
||||
type: NodeType
|
||||
}
|
||||
|
||||
export interface DecisionNode extends TreeNodeBase {
|
||||
type: 'decision'
|
||||
question: string
|
||||
help_text?: string
|
||||
options: TreeOption[]
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
export interface ActionNode extends TreeNodeBase {
|
||||
type: 'action'
|
||||
title: string
|
||||
description: string
|
||||
commands?: string[]
|
||||
expected_outcome?: string
|
||||
next_node_id?: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
export interface SolutionNode extends TreeNodeBase {
|
||||
type: 'solution'
|
||||
title: string
|
||||
description: string
|
||||
resolution_steps?: string[]
|
||||
}
|
||||
|
||||
export type TreeNode = DecisionNode | ActionNode | SolutionNode
|
||||
|
||||
export interface TreeStructure {
|
||||
id: string
|
||||
type: NodeType
|
||||
question?: string
|
||||
title?: string
|
||||
description?: string
|
||||
help_text?: string
|
||||
options?: TreeOption[]
|
||||
commands?: string[]
|
||||
expected_outcome?: string
|
||||
next_node_id?: string
|
||||
resolution_steps?: string[]
|
||||
children?: TreeStructure[]
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface Tree {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
tree_structure: TreeStructure
|
||||
author_id: string | null
|
||||
team_id: string | null
|
||||
is_active: boolean
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
usage_count: number
|
||||
}
|
||||
|
||||
export interface TreeListItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
is_active: boolean
|
||||
version: number
|
||||
usage_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TreeCreate {
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
tree_structure: TreeStructure
|
||||
}
|
||||
|
||||
export interface TreeUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
tree_structure?: TreeStructure
|
||||
is_active?: boolean
|
||||
}
|
||||
28
frontend/src/types/user.ts
Normal file
28
frontend/src/types/user.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type UserRole = 'admin' | 'engineer' | 'viewer'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: UserRole
|
||||
team_id: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
role?: UserRole
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
53
frontend/tailwind.config.js
Normal file
53
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
34
frontend/tsconfig.app.json
Normal file
34
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user