diff --git a/backend/alembic/versions/036_add_workspaces.py b/backend/alembic/versions/036_add_workspaces.py new file mode 100644 index 00000000..89db8f18 --- /dev/null +++ b/backend/alembic/versions/036_add_workspaces.py @@ -0,0 +1,102 @@ +"""Add workspaces table, workspace_id to trees and categories, color to categories + +Revision ID: 036 +Revises: 035 +Create Date: 2026-02-15 + +Adds workspace system for organizational context above folders. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = '036' +down_revision: Union[str, None] = '035' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create workspaces table + op.create_table( + 'workspaces', + sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('slug', sa.String(100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(10), nullable=True), + sa.Column('accent_color', sa.String(7), nullable=True), + sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), + sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'), + ) + op.create_index('ix_workspaces_slug', 'workspaces', ['slug']) + op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id']) + + # Add workspace_id to trees + op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True)) + op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL') + op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id']) + + # Add color and workspace_id to tree_categories + op.add_column('tree_categories', sa.Column('color', sa.String(7), nullable=True, server_default='#3b82f6')) + op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True)) + op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL') + op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id']) + + # Seed default workspaces for each existing account and assign trees + op.execute(""" + -- Create default workspaces for each account + INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order) + SELECT 'Troubleshooting', 'troubleshooting', 'Break/fix decision trees', 'πŸ”§', '#ef4444', a.id, true, 0 + FROM accounts a; + + INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order) + SELECT 'Procedures', 'procedures', 'Step-by-step operational flows', 'πŸ“‹', '#3b82f6', a.id, false, 1 + FROM accounts a; + + INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order) + SELECT 'Policies', 'policies', 'Compliance & policy builders', 'πŸ“œ', '#8b5cf6', a.id, false, 2 + FROM accounts a; + + INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order) + SELECT 'Finance', 'finance', 'Billing & procurement flows', 'πŸ’°', '#22c55e', a.id, false, 3 + FROM accounts a; + + -- Assign existing trees to appropriate workspace based on tree_type + UPDATE trees t + SET workspace_id = w.id + FROM workspaces w + WHERE w.account_id = t.account_id + AND w.slug = 'troubleshooting' + AND t.tree_type = 'troubleshooting'; + + UPDATE trees t + SET workspace_id = w.id + FROM workspaces w + WHERE w.account_id = t.account_id + AND w.slug = 'procedures' + AND t.tree_type = 'procedural'; + """) + + +def downgrade() -> None: + op.drop_index('ix_tree_categories_workspace_id', 'tree_categories') + op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey') + op.drop_column('tree_categories', 'workspace_id') + op.drop_column('tree_categories', 'color') + + op.drop_index('ix_trees_workspace_id', 'trees') + op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey') + op.drop_column('trees', 'workspace_id') + + op.drop_index('ix_workspaces_account_id', 'workspaces') + op.drop_index('ix_workspaces_slug', 'workspaces') + op.drop_table('workspaces') diff --git a/backend/app/api/endpoints/workspaces.py b/backend/app/api/endpoints/workspaces.py new file mode 100644 index 00000000..d4a5405e --- /dev/null +++ b/backend/app/api/endpoints/workspaces.py @@ -0,0 +1,154 @@ +from typing import Annotated +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.models.workspace import Workspace +from app.models.tree import Tree +from app.models.user import User +from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse +from app.api.deps import get_current_active_user + +router = APIRouter(prefix="/workspaces", tags=["workspaces"]) + + +@router.get("", response_model=list[WorkspaceResponse]) +async def list_workspaces( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + """List all workspaces for the user's account.""" + if not current_user.account_id: + return [] + + # Get workspaces with tree counts + query = ( + select( + Workspace, + func.count(Tree.id).label("tree_count") + ) + .outerjoin(Tree, (Tree.workspace_id == Workspace.id) & (Tree.deleted_at.is_(None))) + .where(Workspace.account_id == current_user.account_id) + .group_by(Workspace.id) + .order_by(Workspace.sort_order, Workspace.name) + ) + result = await db.execute(query) + rows = result.all() + + return [ + WorkspaceResponse( + **{c.key: getattr(ws, c.key) for c in Workspace.__table__.columns}, + tree_count=tree_count + ) + for ws, tree_count in rows + ] + + +@router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED) +async def create_workspace( + data: WorkspaceCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Create a new workspace.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="No account found") + + # Check slug uniqueness within account + existing = await db.execute( + select(Workspace).where( + Workspace.account_id == current_user.account_id, + Workspace.slug == data.slug + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Workspace slug already exists") + + workspace = Workspace( + name=data.name, + slug=data.slug, + description=data.description, + icon=data.icon, + accent_color=data.accent_color, + account_id=current_user.account_id, + sort_order=data.sort_order, + ) + db.add(workspace) + await db.flush() + await db.commit() + + return WorkspaceResponse( + **{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns}, + tree_count=0 + ) + + +@router.patch("/{workspace_id}", response_model=WorkspaceResponse) +async def update_workspace( + workspace_id: UUID, + data: WorkspaceUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Update a workspace.""" + workspace = await db.get(Workspace, workspace_id) + if not workspace or workspace.account_id != current_user.account_id: + raise HTTPException(status_code=404, detail="Workspace not found") + + update_data = data.model_dump(exclude_unset=True) + if "slug" in update_data: + existing = await db.execute( + select(Workspace).where( + Workspace.account_id == current_user.account_id, + Workspace.slug == update_data["slug"], + Workspace.id != workspace_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Workspace slug already exists") + + for key, value in update_data.items(): + setattr(workspace, key, value) + + await db.commit() + + # Get tree count + count_result = await db.execute( + select(func.count(Tree.id)).where( + Tree.workspace_id == workspace_id, + Tree.deleted_at.is_(None) + ) + ) + tree_count = count_result.scalar() or 0 + + return WorkspaceResponse( + **{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns}, + tree_count=tree_count + ) + + +@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_workspace( + workspace_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Delete a workspace. Trees are unassigned, not deleted.""" + workspace = await db.get(Workspace, workspace_id) + if not workspace or workspace.account_id != current_user.account_id: + raise HTTPException(status_code=404, detail="Workspace not found") + + if workspace.is_default: + raise HTTPException(status_code=400, detail="Cannot delete the default workspace") + + # Unassign trees (set workspace_id to NULL) + await db.execute( + Tree.__table__.update() + .where(Tree.workspace_id == workspace_id) + .values(workspace_id=None) + ) + + await db.delete(workspace) + await db.commit() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5537529f..5e9db764 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown +from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown, workspaces from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories api_router = APIRouter() @@ -25,3 +25,4 @@ api_router.include_router(webhooks.router) api_router.include_router(shares.router) api_router.include_router(shared.router) # Public endpoints (no auth) api_router.include_router(tree_markdown.router) +api_router.include_router(workspaces.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e2976ccc..e677771a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -20,6 +20,7 @@ from .session_share import SessionShare, SessionShareView from .account_limit_override import AccountLimitOverride from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride from .platform_setting import PlatformSetting +from .workspace import Workspace __all__ = [ "User", @@ -51,4 +52,5 @@ __all__ = [ "PlanFeatureDefault", "AccountFeatureOverride", "PlatformSetting", + "Workspace", ] diff --git a/backend/app/models/account.py b/backend/app/models/account.py index f40d5041..272b8f9a 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from app.models.step_category import StepCategory from app.models.step_library import StepLibrary from app.models.account_limit_override import AccountLimitOverride + from app.models.workspace import Workspace class Account(Base): @@ -45,3 +46,4 @@ class Account(Base): step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account") step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account") limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False) + workspaces: Mapped[list["Workspace"]] = relationship("Workspace", back_populates="account") diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 57bae701..8923ecb0 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from app.models.team import Team from app.models.account import Account from app.models.user import User + from app.models.workspace import Workspace class TreeCategory(Base): @@ -47,6 +48,17 @@ class TreeCategory(Base): ) display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + color: Mapped[Optional[str]] = mapped_column( + String(7), nullable=True, default='#3b82f6', + comment="Hex color for category dot indicator" + ) + workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="Workspace this category belongs to" + ) created_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), @@ -67,6 +79,7 @@ class TreeCategory(Base): account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="categories") creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by]) trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="category_rel") + workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="categories") @property def is_global(self) -> bool: diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index f36dd961..a8dcd51a 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from app.models.tag import TreeTag from app.models.folder import UserFolder from app.models.tree_share import TreeShare + from app.models.workspace import Workspace class Tree(Base): @@ -120,6 +121,13 @@ class Tree(Base): onupdate=lambda: datetime.now(timezone.utc) ) usage_count: Mapped[int] = mapped_column(Integer, default=0) + workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="Workspace this tree belongs to (organizational context)" + ) # Fork tracking parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( @@ -184,6 +192,8 @@ class Tree(Base): cascade="all, delete-orphan" ) + workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="trees") + # New organization relationships category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees") tags: Mapped[list["TreeTag"]] = relationship( diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py new file mode 100644 index 00000000..7e11c4bd --- /dev/null +++ b/backend/app/models/workspace.py @@ -0,0 +1,57 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.account import Account + from app.models.tree import Tree + from app.models.category import TreeCategory + + +class Workspace(Base): + """Workspaces are the top-level organizational context for trees/flows. + + They sit above the folder system β€” a workspace scopes which trees/flows + are visible, while folders remain for personal organization within. + """ + __tablename__ = "workspaces" + __table_args__ = ( + UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + icon: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) + accent_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc) + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="workspaces") + trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="workspace") + categories: Mapped[list["TreeCategory"]] = relationship("TreeCategory", back_populates="workspace") diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py index 13e9955c..11475d23 100644 --- a/backend/app/schemas/category.py +++ b/backend/app/schemas/category.py @@ -36,6 +36,8 @@ class CategoryResponse(CategoryBase): account_id: Optional[UUID] = None display_order: int is_active: bool + color: Optional[str] = None + workspace_id: Optional[UUID] = None created_at: datetime updated_at: datetime tree_count: int = 0 # Computed field @@ -52,6 +54,8 @@ class CategoryListResponse(BaseModel): account_id: Optional[UUID] = None display_order: int is_active: bool + color: Optional[str] = None + workspace_id: Optional[UUID] = None tree_count: int = 0 class Config: diff --git a/backend/app/schemas/workspace.py b/backend/app/schemas/workspace.py new file mode 100644 index 00000000..a32aea26 --- /dev/null +++ b/backend/app/schemas/workspace.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +class WorkspaceCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$') + description: Optional[str] = None + icon: Optional[str] = Field(None, max_length=10) + accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$') + sort_order: int = 0 + + +class WorkspaceUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$') + description: Optional[str] = None + icon: Optional[str] = Field(None, max_length=10) + accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$') + sort_order: Optional[int] = None + + +class WorkspaceResponse(BaseModel): + id: uuid.UUID + name: str + slug: str + description: Optional[str] = None + icon: Optional[str] = None + accent_color: Optional[str] = None + account_id: uuid.UUID + is_default: bool + sort_order: int + tree_count: int = 0 + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5fabfc6d..fdea0cc7 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -11,3 +11,4 @@ export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' +export { default as workspacesApi } from './workspaces' diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts new file mode 100644 index 00000000..75e09e38 --- /dev/null +++ b/frontend/src/api/workspaces.ts @@ -0,0 +1,25 @@ +import { apiClient } from './client' +import type { Workspace, WorkspaceCreate, WorkspaceUpdate } from '@/types' + +export const workspacesApi = { + list: async (): Promise => { + const { data } = await apiClient.get('/workspaces') + return data + }, + + create: async (payload: WorkspaceCreate): Promise => { + const { data } = await apiClient.post('/workspaces', payload) + return data + }, + + update: async (id: string, payload: WorkspaceUpdate): Promise => { + const { data } = await apiClient.patch(`/workspaces/${id}`, payload) + return data + }, + + delete: async (id: string): Promise => { + await apiClient.delete(`/workspaces/${id}`) + }, +} + +export default workspacesApi diff --git a/frontend/src/components/dashboard/FiltersBar.tsx b/frontend/src/components/dashboard/FiltersBar.tsx new file mode 100644 index 00000000..e4851620 --- /dev/null +++ b/frontend/src/components/dashboard/FiltersBar.tsx @@ -0,0 +1,39 @@ +import { Filter } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface FilterChip { + id: string + label: string +} + +interface FiltersBarProps { + filters: FilterChip[] + activeFilter: string + onFilterChange: (id: string) => void +} + +export function FiltersBar({ filters, activeFilter, onFilterChange }: FiltersBarProps) { + return ( +
+ {filters.map(f => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard/QuickStats.tsx b/frontend/src/components/dashboard/QuickStats.tsx new file mode 100644 index 00000000..4cc2412e --- /dev/null +++ b/frontend/src/components/dashboard/QuickStats.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils' + +interface StatCard { + label: string + value: string | number + meta?: string + gradient?: boolean + color?: string +} + +interface QuickStatsProps { + stats: StatCard[] +} + +export function QuickStats({ stats }: QuickStatsProps) { + return ( +
+ {stats.map((stat, i) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+ {stat.meta && ( +

{stat.meta}

+ )} +
+ ))} +
+ ) +} diff --git a/frontend/src/components/dashboard/SectionGroup.tsx b/frontend/src/components/dashboard/SectionGroup.tsx new file mode 100644 index 00000000..bfd448d4 --- /dev/null +++ b/frontend/src/components/dashboard/SectionGroup.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface SectionGroupProps { + title: string + count?: number + defaultOpen?: boolean + delay?: number + children: React.ReactNode +} + +export function SectionGroup({ title, count, defaultOpen = true, delay = 150, children }: SectionGroupProps) { + const [open, setOpen] = useState(defaultOpen) + + return ( +
+ + {open &&
{children}
} +
+ ) +} diff --git a/frontend/src/components/dashboard/SessionsPanel.tsx b/frontend/src/components/dashboard/SessionsPanel.tsx new file mode 100644 index 00000000..a2c2bade --- /dev/null +++ b/frontend/src/components/dashboard/SessionsPanel.tsx @@ -0,0 +1,75 @@ +import { Link } from 'react-router-dom' +import { cn } from '@/lib/utils' + +interface SessionItem { + id: string + treeName: string + status: 'in_progress' | 'completed' | 'abandoned' + currentStep?: string + totalSteps?: number + stepNumber?: number + ticketNumber?: string + timeAgo: string +} + +interface SessionsPanelProps { + sessions: SessionItem[] + delay?: number +} + +export function SessionsPanel({ sessions, delay = 200 }: SessionsPanelProps) { + if (sessions.length === 0) return null + + return ( +
+
+

Recent Sessions

+ + View All + +
+
+ {sessions.map(session => ( + + {/* Status dot */} + + + {/* Name */} + {session.treeName} + + {/* Progress */} + + {session.status === 'completed' + ? 'βœ“ Resolved' + : session.stepNumber && session.totalSteps + ? `β†’ step ${session.stepNumber}/${session.totalSteps}` + : 'β†’ In progress'} + + + {/* Ticket */} + + {session.ticketNumber || 'β€”'} + + + {/* Time */} + + {session.timeAgo} + + + ))} +
+
+ ) +} diff --git a/frontend/src/components/dashboard/TreeListItem.tsx b/frontend/src/components/dashboard/TreeListItem.tsx new file mode 100644 index 00000000..22d80a39 --- /dev/null +++ b/frontend/src/components/dashboard/TreeListItem.tsx @@ -0,0 +1,109 @@ +import { useNavigate } from 'react-router-dom' +import { MoreHorizontal } from 'lucide-react' +import { getTreeNavigatePath, getTreeEditorPath } from '@/lib/routing' + +interface TreeListItemProps { + id: string + name: string + description?: string | null + treeType: string + category?: { name: string; color?: string } | null + tags?: string[] + usageCount?: number + updatedAt: string + icon?: string +} + +export function TreeListItem({ + id, + name, + description, + treeType, + category, + tags = [], + usageCount = 0, + updatedAt, + icon, +}: TreeListItemProps) { + const navigate = useNavigate() + const categoryColor = category?.color || '#3b82f6' + + const timeAgo = getTimeAgo(updatedAt) + + return ( +
navigate(getTreeNavigatePath(id, treeType))} + className="group grid cursor-pointer items-center gap-3 rounded-lg border border-transparent bg-card px-4 py-3 transition-colors hover:border-border hover:bg-[hsl(var(--sidebar-hover))]" + style={{ gridTemplateColumns: '40px 1fr 130px 80px 100px 40px' }} + > + {/* Icon box */} +
+ {icon || (treeType === 'procedural' ? 'πŸ“‹' : 'πŸ”§')} +
+ + {/* Info */} +
+

{name}

+
+ {tags.slice(0, 3).map(tag => ( + + {tag} + + ))} + {description && tags.length === 0 && ( + {description} + )} +
+
+ + {/* Category */} +
+ {category && ( + <> + + {category.name} + + )} +
+ + {/* Usage count */} +
+ {usageCount} uses +
+ + {/* Updated */} +
+ {timeAgo} +
+ + {/* Actions */} + +
+ ) +} + +function getTimeAgo(dateStr: string): string { + const now = Date.now() + const date = new Date(dateStr).getTime() + const diff = now - date + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return 'Just now' + if (minutes < 60) return `${minutes} min ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days === 1) return 'Yesterday' + if (days < 7) return `${days}d ago` + return new Date(dateStr).toLocaleDateString() +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 8ee7639f..32ffe8fc 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,61 +1,39 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom' +import { useEffect, useState, useCallback } from 'react' +import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom' +import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, LogOut, Shield } from 'lucide-react' import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' +import { useWorkspaceStore } from '@/store/workspaceStore' import { BrandLogo } from '@/components/common/BrandLogo' -import { Menu, X, LogOut, User, Shield, ChevronDown, FolderTree, ListOrdered, Layers } from 'lucide-react' +import { TopBar } from './TopBar' +import { Sidebar } from './Sidebar' import { cn } from '@/lib/utils' -interface NavItem { - path: string - label: string - children?: { path: string; label: string; icon: React.ReactNode }[] -} - export function AppLayout() { const location = useLocation() const navigate = useNavigate() const { user, logout } = useAuthStore() - const { effectiveRole, isSuperAdmin } = usePermissions() + const { effectiveRole } = usePermissions() + const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const [flowsDropdownOpen, setFlowsDropdownOpen] = useState(false) - const flowsDropdownRef = useRef(null) - const handleLogout = async () => { - setMobileMenuOpen(false) - await logout() - navigate('/login') - } + // Fetch workspaces on mount + useEffect(() => { + fetchWorkspaces() + }, [fetchWorkspaces]) // Close mobile menu on route change const [prevPath, setPrevPath] = useState(location.pathname) if (prevPath !== location.pathname) { setPrevPath(location.pathname) if (mobileMenuOpen) setMobileMenuOpen(false) - setFlowsDropdownOpen(false) } // Close on Escape const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') { - setMobileMenuOpen(false) - setFlowsDropdownOpen(false) - } + if (e.key === 'Escape') setMobileMenuOpen(false) }, []) - // Close dropdown on outside click - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (flowsDropdownRef.current && !flowsDropdownRef.current.contains(e.target as Node)) { - setFlowsDropdownOpen(false) - } - } - if (flowsDropdownOpen) { - document.addEventListener('mousedown', handleClickOutside) - } - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [flowsDropdownOpen]) - useEffect(() => { if (mobileMenuOpen) { document.addEventListener('keydown', handleKeyDown) @@ -69,250 +47,98 @@ export function AppLayout() { } }, [mobileMenuOpen, handleKeyDown]) - const isFlowsActive = location.pathname.startsWith('/trees') || location.pathname.startsWith('/flows') + const handleLogout = async () => { + setMobileMenuOpen(false) + await logout() + navigate('/login') + } - const navItems: NavItem[] = [ - { path: '/', label: 'Home' }, - { - path: '/trees', - label: 'Flows', - children: [ - { path: '/trees', label: 'All Flows', icon: }, - { path: '/trees?type=troubleshooting', label: 'Troubleshooting', icon: }, - { path: '/trees?type=procedural', label: 'Procedures', icon: }, - ], - }, - { path: '/my-trees', label: 'My Flows' }, - { path: '/sessions', label: 'Sessions' }, - { path: '/shares', label: 'My Shares' }, - { path: '/account', label: 'Account' }, - ...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []), + const mobileNavItems = [ + { path: '/', label: 'Dashboard', icon: LayoutGrid }, + { path: '/trees', label: 'All Flows', icon: Box }, + { path: '/my-trees', label: 'My Flows', icon: PenLine }, + { path: '/sessions', label: 'Sessions', icon: Clock }, + { path: '/shares', label: 'Exports', icon: FileText }, + { path: '/step-library', label: 'Step Library', icon: Bookmark }, + { path: '/account', label: 'Team', icon: Users }, + { path: '/account', label: 'Settings', icon: Settings }, ] return ( -
- {/* Subtle radial overlay for depth */} -
+
+ {/* Top Bar - spans full width */} + - {/* Header */} -
-
-
- {/* Mobile hamburger */} - + {/* Sidebar - desktop only */} +
+ +
- {/* Logo */} - -
- -
- - ResolutionFlow - - - - {/* Desktop Navigation */} - -
- - {/* Right side controls */} -
- {/* User info */} -
-
- - - {user?.name || user?.email} - -
- - {/* Role badge */} - {effectiveRole && effectiveRole !== 'engineer' && ( -
- - - {effectiveRole === 'super_admin' ? 'Super Admin' : - effectiveRole === 'owner' ? 'Owner' : - 'Viewer'} - -
- )} -
- - {/* Logout button */} - -
-
-
+ {/* Mobile hamburger - overlaid on topbar */} + {/* Mobile Nav Drawer */} {mobileMenuOpen && ( -
- {/* Backdrop */} +
setMobileMenuOpen(false)} aria-hidden="true" /> - - {/* Drawer */} -